From 707bcb05791119fa323e553c6aab5f6bec8c8142 Mon Sep 17 00:00:00 2001 From: udifuchs Date: Fri, 1 Dec 2023 13:45:26 -0600 Subject: [PATCH 1/2] Added type annotations to svg.path. All source code and tests pass the `mypy --strict` checks. --- .github/workflows/integration.yml | 4 +- CHANGES.txt | 2 +- MANIFEST.in | 1 + Makefile | 1 + setup.cfg | 9 ++ src/svg/path/__init__.py | 22 ++- src/svg/path/parser.py | 52 ++++-- src/svg/path/path.py | 261 +++++++++++++++++++----------- src/svg/path/py.typed | 0 tests/test_boundingbox_image.py | 27 ++-- tests/test_doc.py | 2 +- tests/test_generation.py | 4 +- tests/test_image.py | 29 ++-- tests/test_parsing.py | 54 +++---- tests/test_paths.py | 61 +++---- tests/test_tokenizer.py | 17 +- 16 files changed, 343 insertions(+), 203 deletions(-) create mode 100644 src/svg/path/py.typed diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 6a86507..dc4ee3a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -25,12 +25,14 @@ jobs: - name: Upgrade pip run: python -m pip install --upgrade pip - name: Install tools - run: pip install flake8 black + run: pip install flake8 black mypy - name: Install package run: pip install -e ".[test]" - name: Run black run: black --quiet --check . - name: Run flake8 run: flake8 . + - name: Run mypy + run: mypy - name: Run tests run: pytest diff --git a/CHANGES.txt b/CHANGES.txt index 8b2168b..2edc6c8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -5,7 +5,7 @@ Changelog 6.4 (unreleased) ---------------- -- Nothing changed yet. +- Added type annotations. 6.3 (2023-04-29) diff --git a/MANIFEST.in b/MANIFEST.in index b24a550..3264a56 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include *.rst include *.txt +include src/svg/path/py.typed exclude Makefile recursive-exclude tests * diff --git a/Makefile b/Makefile index b556450..ee5b60e 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,7 @@ $(bin_dir)fullrelease: $(bin_dir) check: devenv $(bin_dir)black src tests $(bin_dir)flake8 src tests + $(bin_dir)mypy $(bin_dir)pyroma -d . $(bin_dir)check-manifest diff --git a/setup.cfg b/setup.cfg index 8971d1a..7d9c12b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,8 @@ test = Pillow black flake8 + mypy + types-Pillow pyroma check-manifest zest.releaser[recommended] @@ -54,3 +56,10 @@ universal=1 [tool:pytest] testpaths = tests + +[mypy] +files = + src, + tests + +strict = True diff --git a/src/svg/path/__init__.py b/src/svg/path/__init__.py index 6f30a5d..cbaa01b 100644 --- a/src/svg/path/__init__.py +++ b/src/svg/path/__init__.py @@ -1,4 +1,18 @@ -from .path import Path, Move, Line, Arc, Close # noqa: 401 -from .path import CubicBezier, QuadraticBezier # noqa: 401 -from .path import PathSegment, Linear, NonLinear # noqa: 401 -from .parser import parse_path # noqa: 401 +from .path import Path, Move, Line, Arc, Close +from .path import CubicBezier, QuadraticBezier +from .path import PathSegment, Linear, NonLinear +from .parser import parse_path + +__all__ = ( + "Path", + "Move", + "Line", + "Arc", + "Close", + "CubicBezier", + "QuadraticBezier", + "PathSegment", + "Linear", + "NonLinear", + "parse_path", +) diff --git a/src/svg/path/parser.py b/src/svg/path/parser.py index 8b3ffee..3161641 100644 --- a/src/svg/path/parser.py +++ b/src/svg/path/parser.py @@ -1,5 +1,6 @@ # SVG Path specification parser +from typing import Generator, Tuple, Union import re from svg.path import path @@ -33,14 +34,14 @@ class InvalidPathError(ValueError): } -def strip_array(arg_array): +def strip_array(arg_array: bytearray) -> None: """Strips whitespace and commas""" # EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C while arg_array and arg_array[0] in (0x20, 0x9, 0xD, 0xA, 0x2C): arg_array[0:1] = b"" -def pop_number(arg_array): +def pop_number(arg_array: bytearray) -> float: res = FLOAT_RE.search(arg_array) if not res or not res.group(): raise InvalidPathError(f"Expected a number, got '{arg_array}'.") @@ -53,20 +54,20 @@ def pop_number(arg_array): return number -def pop_unsigned_number(arg_array): +def pop_unsigned_number(arg_array: bytearray) -> float: number = pop_number(arg_array) if number < 0: raise InvalidPathError(f"Expected a non-negative number, got '{number}'.") return number -def pop_coordinate_pair(arg_array): +def pop_coordinate_pair(arg_array: bytearray) -> complex: x = pop_number(arg_array) y = pop_number(arg_array) return complex(x, y) -def pop_flag(arg_array): +def pop_flag(arg_array: bytearray) -> Union[bool, None]: flag = arg_array[0] arg_array[0:1] = b"" strip_array(arg_array) @@ -74,6 +75,7 @@ def pop_flag(arg_array): return False if flag == 49: # ASCII 1 return True + return None FIELD_POPPERS = { @@ -84,9 +86,9 @@ def pop_flag(arg_array): } -def _commandify_path(pathdef): +def _commandify_path(pathdef: str) -> Generator[Tuple[str, ...], None, None]: """Splits path into commands and arguments""" - token = None + token: Union[Tuple[str, ...], None] = None for x in COMMAND_RE.split(pathdef): x = x.strip() if x in COMMANDS: @@ -101,10 +103,14 @@ def _commandify_path(pathdef): if token is None: raise InvalidPathError(f"Path does not start with a command: {pathdef}") token += (x,) - yield token + # Logically token cannot be None, but mypy cannot deduce this. + if token is not None: + yield token -def _tokenize_path(pathdef): +def _tokenize_path( + pathdef: str, +) -> Generator[Tuple[Union[str, complex, float, bool, None], ...], None, None]: for command, args in _commandify_path(pathdef): # Shortcut this for the close command, that doesn't have arguments: if command in ("z", "Z"): @@ -138,18 +144,20 @@ def _tokenize_path(pathdef): command = "L" -def parse_path(pathdef): +def parse_path(pathdef: str) -> path.Path: segments = path.Path() start_pos = None - last_command = None - current_pos = 0 + last_command = "No last command" + current_pos = 0j for token in _tokenize_path(pathdef): command = token[0] + assert isinstance(command, str) relative = command.islower() command = command.upper() if command == "M": pos = token[1] + assert isinstance(pos, complex) if relative: current_pos += pos else: @@ -160,11 +168,13 @@ def parse_path(pathdef): elif command == "Z": # For Close commands the "relative" argument just preserves case, # it has no different in behavior. + assert isinstance(start_pos, complex) segments.append(path.Close(current_pos, start_pos, relative=relative)) current_pos = start_pos elif command == "L": pos = token[1] + assert isinstance(pos, complex) if relative: pos += current_pos segments.append(path.Line(current_pos, pos, relative=relative)) @@ -172,6 +182,7 @@ def parse_path(pathdef): elif command == "H": hpos = token[1] + assert isinstance(hpos, float) if relative: hpos += current_pos.real pos = complex(hpos, current_pos.imag) @@ -182,6 +193,7 @@ def parse_path(pathdef): elif command == "V": vpos = token[1] + assert isinstance(vpos, float) if relative: vpos += current_pos.imag pos = complex(current_pos.real, vpos) @@ -192,8 +204,11 @@ def parse_path(pathdef): elif command == "C": control1 = token[1] + assert isinstance(control1, complex) control2 = token[2] + assert isinstance(control2, complex) end = token[3] + assert isinstance(end, complex) if relative: control1 += current_pos @@ -211,7 +226,9 @@ def parse_path(pathdef): # Smooth curve. First control point is the "reflection" of # the second control point in the previous path. control2 = token[1] + assert isinstance(control2, complex) end = token[2] + assert isinstance(end, complex) if relative: control2 += current_pos @@ -221,6 +238,7 @@ def parse_path(pathdef): # The first control point is assumed to be the reflection of # the second control point on the previous command relative # to the current point. + assert isinstance(segments[-1], path.CubicBezier) control1 = current_pos + current_pos - segments[-1].control2 else: # If there is no previous command or if the previous command @@ -237,7 +255,9 @@ def parse_path(pathdef): elif command == "Q": control = token[1] + assert isinstance(control, complex) end = token[2] + assert isinstance(end, complex) if relative: control += current_pos @@ -252,6 +272,7 @@ def parse_path(pathdef): # Smooth curve. Control point is the "reflection" of # the second control point in the previous path. end = token[1] + assert isinstance(end, complex) if relative: end += current_pos @@ -260,6 +281,7 @@ def parse_path(pathdef): # The control point is assumed to be the reflection of # the control point on the previous command relative # to the current point. + assert isinstance(segments[-1], path.QuadraticBezier) control = current_pos + current_pos - segments[-1].control else: # If there is no previous command or if the previous command @@ -277,11 +299,17 @@ def parse_path(pathdef): elif command == "A": # For some reason I implemented the Arc with a complex radius. # That doesn't really make much sense, but... *shrugs* + assert isinstance(token[1], float) + assert isinstance(token[2], float) radius = complex(token[1], token[2]) rotation = token[3] + assert isinstance(rotation, float) arc = token[4] + assert isinstance(arc, (bool, int)) sweep = token[5] + assert isinstance(sweep, (bool, int)) end = token[6] + assert isinstance(end, complex) if relative: end += current_pos diff --git a/src/svg/path/path.py b/src/svg/path/path.py index 318f6c1..4c88f36 100644 --- a/src/svg/path/path.py +++ b/src/svg/path/path.py @@ -1,4 +1,6 @@ +from __future__ import annotations from math import sqrt, cos, sin, acos, degrees, radians, log, pi +from typing import List, Tuple, Union from bisect import bisect from abc import ABC, abstractmethod import math @@ -15,7 +17,7 @@ ERROR = 1e-12 -def _find_solutions_for_bezier(c2, c1, c0): +def _find_solutions_for_bezier(c2: float, c1: float, c0: float) -> List[float]: """Find solutions of c2 * t^2 + c1 * t + c0 = 0 where t in [0, 1]""" soln = [] if c2 == 0: @@ -29,7 +31,7 @@ def _find_solutions_for_bezier(c2, c1, c0): return [s for s in soln if 0.0 <= s and s <= 1.0] -def _find_solutions_for_arc(a, b, c, d): +def _find_solutions_for_arc(a: float, b: float, c: float, d: float) -> List[float]: """Find solution for a sin(x) + b cos(x) = 0 where x = c + d * t and t in [0, 1]""" if a == 0: # when n \in Z @@ -70,7 +72,16 @@ def _find_solutions_for_arc(a, b, c, d): return t_list -def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth): +def segment_length( + curve: NonLinear, + start: float, + end: float, + start_point: complex, + end_point: complex, + error: float, + min_depth: int, + depth: int, +) -> float: """Recursively approximates the length by straight lines""" mid = (start + end) / 2 mid_point = curve.point(mid) @@ -92,20 +103,27 @@ def segment_length(curve, start, end, start_point, end_point, error, min_depth, class PathSegment(ABC): + start: complex + end: complex + + @abstractmethod + def _d(self, previous: PathSegment) -> str: + pass + @abstractmethod - def point(self, pos): + def point(self, pos: float) -> complex: """Returns the coordinate point (as a complex number) of a point on the path, as expressed as a floating point number between 0 (start) and 1 (end). """ @abstractmethod - def tangent(self, pos): + def tangent(self, pos: float) -> complex: """Returns a vector (as a complex number) representing the tangent of a point on the path as expressed as a floating point number between 0 (start) and 1 (end). """ @abstractmethod - def length(self, error=ERROR, min_depth=MIN_DEPTH): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: """Returns the length of a path. The CubicBezier and Arc lengths are non-exact and iterative and you can select to @@ -113,8 +131,10 @@ def length(self, error=ERROR, min_depth=MIN_DEPTH): number of iterations. """ + # TODO: It would be more appropriate to have a return type of: + # Tuple[float, float, float, float] @abstractmethod - def boundingbox(self): + def boundingbox(self) -> List[float]: """Returns the bounding box of a path in the format of [left, top, right, bottom]""" @@ -131,69 +151,82 @@ class Linear(PathSegment): The base for Line() and Close(). """ - def __init__(self, start, end, relative=False): + def __init__(self, start: complex, end: complex, relative: bool = False) -> None: self.start = start self.end = end self.relative = relative - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Line): return NotImplemented return not self == other - def point(self, pos): + def point(self, pos: float) -> complex: distance = self.end - self.start return self.start + distance * pos - def tangent(self, pos): + def tangent(self, pos: float) -> complex: return self.end - self.start - def length(self, error=None, min_depth=None): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: distance = self.end - self.start return sqrt(distance.real**2 + distance.imag**2) class Line(Linear): - def __init__(self, start, end, relative=False, vertical=False, horizontal=False): + def __init__( + self, + start: complex, + end: complex, + relative: bool = False, + vertical: bool = False, + horizontal: bool = False, + ) -> None: self.start = start self.end = end self.relative = relative self.vertical = vertical self.horizontal = horizontal - def __repr__(self): + def __repr__(self) -> str: return f"Line(start={self.start}, end={self.end})" - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Line): return NotImplemented return self.start == other.start and self.end == other.end - def _d(self, previous): + def _d(self, previous: PathSegment) -> str: x = self.end.real y = self.end.imag if self.relative: x -= previous.end.real y -= previous.end.imag - if self.horizontal and self.is_horizontal_from(previous): + # TODO: Add check that `previous` is Line instance. + # mypy error: + # Argument 1 to "is_horizontal_from" of "Line" has incompatible type "PathSegment"; expected "Line" + if self.horizontal and self.is_horizontal_from(previous): # type: ignore[arg-type] cmd = "h" if self.relative else "H" return f"{cmd} {x:G}" - if self.vertical and self.is_vertical_from(previous): + # TODO: Add check that previous is Line instance. + # mypy error: + # Argument 1 to "is_vertical_from" of "Line" has incompatible type "PathSegment"; expected "Line" + if self.vertical and self.is_vertical_from(previous): # type: ignore[arg-type] cmd = "v" if self.relative else "V" return f"{cmd} {y:G}" cmd = "l" if self.relative else "L" return f"{cmd} {x:G},{y:G}" - def is_vertical_from(self, previous): + def is_vertical_from(self, previous: Line) -> bool: return self.start == previous.end and self.start.real == self.end.real - def is_horizontal_from(self, previous): + def is_horizontal_from(self, previous: Line) -> bool: return self.start == previous.end and self.start.imag == self.end.imag - def boundingbox(self): + def boundingbox(self) -> List[float]: x_min = min(self.start.real, self.end.real) x_max = max(self.start.real, self.end.real) y_min = min(self.start.imag, self.end.imag) @@ -202,7 +235,15 @@ def boundingbox(self): class CubicBezier(NonLinear): - def __init__(self, start, control1, control2, end, relative=False, smooth=False): + def __init__( + self, + start: complex, + control1: complex, + control2: complex, + end: complex, + relative: bool = False, + smooth: bool = False, + ): self.start = start self.control1 = control1 self.control2 = control2 @@ -210,13 +251,13 @@ def __init__(self, start, control1, control2, end, relative=False, smooth=False) self.relative = relative self.smooth = smooth - def __repr__(self): + def __repr__(self) -> str: return ( f"CubicBezier(start={self.start}, control1={self.control1}, " f"control2={self.control2}, end={self.end}, smooth={self.smooth})" ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, CubicBezier): return NotImplemented return ( @@ -226,12 +267,12 @@ def __eq__(self, other): and self.control2 == other.control2 ) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, CubicBezier): return NotImplemented return not self == other - def _d(self, previous): + def _d(self, previous: PathSegment) -> str: c1 = self.control1 c2 = self.control2 end = self.end @@ -241,14 +282,18 @@ def _d(self, previous): c2 -= previous.end end -= previous.end - if self.smooth and self.is_smooth_from(previous): + # TODO: Add check that previous is CubicBezier instance. + # mypy error: + # Argument 1 to "is_smooth_from" of "CubicBezier" has incompatible type + # "PathSegment"; expected "CubicBezier" [arg-type] + if self.smooth and self.is_smooth_from(previous): # type: ignore[arg-type] cmd = "s" if self.relative else "S" return f"{cmd} {c2.real:G},{c2.imag:G} {end.real:G},{end.imag:G}" cmd = "c" if self.relative else "C" return f"{cmd} {c1.real:G},{c1.imag:G} {c2.real:G},{c2.imag:G} {end.real:G},{end.imag:G}" - def is_smooth_from(self, previous): + def is_smooth_from(self, previous: CubicBezier) -> bool: """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, CubicBezier): return self.start == previous.end and (self.control1 - self.start) == ( @@ -257,13 +302,13 @@ def is_smooth_from(self, previous): else: return self.control1 == self.start - def set_smooth_from(self, previous): + def set_smooth_from(self, previous: CubicBezier) -> None: assert isinstance(previous, CubicBezier) self.start = previous.end self.control1 = previous.end - previous.control2 + self.start self.smooth = True - def point(self, pos): + def point(self, pos: float) -> complex: """Calculate the x,y position at a certain position of the path""" return ( ((1 - pos) ** 3 * self.start) @@ -272,7 +317,7 @@ def point(self, pos): + (pos**3 * self.end) ) - def tangent(self, pos): + def tangent(self, pos: float) -> complex: return ( -3 * (1 - pos) ** 2 * self.start + 3 * (1 - pos) ** 2 * self.control1 @@ -282,13 +327,13 @@ def tangent(self, pos): + 3 * pos**2 * self.end ) - def length(self, error=ERROR, min_depth=MIN_DEPTH): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: """Calculate the length of the path up to a certain position""" start_point = self.point(0) end_point = self.point(1) return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) - def boundingbox(self): + def boundingbox(self) -> List[float]: """Calculate the bounding box of a cubic Bezier curve. A cubic Bezier curve and its derivative are given as follows. @@ -325,20 +370,27 @@ def boundingbox(self): class QuadraticBezier(NonLinear): - def __init__(self, start, control, end, relative=False, smooth=False): + def __init__( + self, + start: complex, + control: complex, + end: complex, + relative: bool = False, + smooth: bool = False, + ) -> None: self.start = start self.end = end self.control = control self.relative = relative self.smooth = smooth - def __repr__(self): + def __repr__(self) -> str: return ( f"QuadraticBezier(start={self.start}, control={self.control}, " f"end={self.end}, smooth={self.smooth})" ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, QuadraticBezier): return NotImplemented return ( @@ -347,26 +399,30 @@ def __eq__(self, other): and self.control == other.control ) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, QuadraticBezier): return NotImplemented return not self == other - def _d(self, previous): + def _d(self, previous: PathSegment) -> str: control = self.control end = self.end if self.relative and previous: control -= previous.end end -= previous.end - if self.smooth and self.is_smooth_from(previous): + # TODO: Add check that previous is QuadraticBezier instance. + # mypy error: + # Argument 1 to "is_smooth_from" of "QuadraticBezier" has incompatible type + # "PathSegment"; expected "QuadraticBezier" [arg-type] + if self.smooth and self.is_smooth_from(previous): # type: ignore[arg-type] cmd = "t" if self.relative else "T" return f"{cmd} {end.real:G},{end.imag:G}" cmd = "q" if self.relative else "Q" return f"{cmd} {control.real:G},{control.imag:G} {end.real:G},{end.imag:G}" - def is_smooth_from(self, previous): + def is_smooth_from(self, previous: QuadraticBezier) -> bool: """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, QuadraticBezier): return self.start == previous.end and (self.control - self.start) == ( @@ -375,27 +431,27 @@ def is_smooth_from(self, previous): else: return self.control == self.start - def set_smooth_from(self, previous): + def set_smooth_from(self, previous: QuadraticBezier) -> None: assert isinstance(previous, QuadraticBezier) self.start = previous.end self.control = previous.end - previous.control + self.start self.smooth = True - def point(self, pos): + def point(self, pos: float) -> complex: return ( (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + pos**2 * self.end ) - def tangent(self, pos): + def tangent(self, pos: float) -> complex: return ( self.start * (2 * pos - 2) + (2 * self.end - 4 * self.control) * pos + 2 * self.control ) - def length(self, error=None, min_depth=None): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: a = self.start - 2 * self.control + self.end b = 2 * (self.control - self.start) @@ -428,7 +484,7 @@ def length(self, error=None, min_depth=None): s = abs(a) * (k**2 / 2 - k + 1) return s - def boundingbox(self): + def boundingbox(self) -> List[float]: """Calculate the bounding box of a quadratic Bezier curve. A quadratic Bezier curve and its derivative are given as follows. @@ -463,7 +519,16 @@ def boundingbox(self): class Arc(NonLinear): - def __init__(self, start, radius, rotation, arc, sweep, end, relative=False): + def __init__( + self, + start: complex, + radius: complex, + rotation: float, + arc: Union[bool, int], + sweep: Union[bool, int], + end: complex, + relative: bool = False, + ) -> None: """radius is complex, rotation is in degrees, large and sweep are 1 or 0 (True/False also work)""" @@ -477,13 +542,13 @@ def __init__(self, start, radius, rotation, arc, sweep, end, relative=False): self._parameterize() - def __repr__(self): + def __repr__(self) -> str: return ( f"Arc(start={self.start}, radius={self.radius}, rotation={self.rotation}, " f"arc={self.arc}, sweep={self.sweep}, end={self.end})" ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Arc): return NotImplemented return ( @@ -495,12 +560,12 @@ def __eq__(self, other): and self.sweep == other.sweep ) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Arc): return NotImplemented return not self == other - def _d(self, previous): + def _d(self, previous: PathSegment) -> str: end = self.end cmd = "a" if self.relative else "A" if self.relative: @@ -511,7 +576,7 @@ def _d(self, previous): f"{int(self.arc):d},{int(self.sweep):d} {end.real:G},{end.imag:G}" ) - def _parameterize(self): + def _parameterize(self) -> None: # Conversion from endpoint to center parameterization # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes if self.start == self.end: @@ -590,7 +655,7 @@ def _parameterize(self): if not self.sweep: self.delta -= 360 - def point(self, pos): + def point(self, pos: float) -> complex: if self.start == self.end: # This is equivalent of omitting the segment return self.start @@ -617,7 +682,7 @@ def point(self, pos): ) return complex(x, y) - def tangent(self, pos): + def tangent(self, pos: float) -> complex: angle = radians(self.theta + (self.delta * pos)) cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) @@ -627,7 +692,7 @@ def tangent(self, pos): y = sinr * cos(angle) * radius.real + cosr * sin(angle) * radius.imag return complex(x, y) * complex(0, 1) - def length(self, error=ERROR, min_depth=MIN_DEPTH): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: """The length of an elliptical arc segment requires numerical integration, and in that case it's simpler to just do a geometric approximation, as for cubic bezier curves. @@ -650,7 +715,7 @@ def length(self, error=ERROR, min_depth=MIN_DEPTH): end_point = self.point(1) return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) - def boundingbox(self): + def boundingbox(self) -> List[float]: """Calculate the bounding box of an arc To calculate the extremums of the arc coordinates, we solve @@ -703,24 +768,24 @@ class Move: paths that consist of only move commands, which is valid, but pointless. """ - def __init__(self, to, relative=False): + def __init__(self, to: complex, relative: bool = False) -> None: self.start = self.end = to self.relative = relative - def __repr__(self): + def __repr__(self) -> str: return "Move(to=%s)" % self.start - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Move): return NotImplemented return self.start == other.start - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Move): return NotImplemented return not self == other - def _d(self, previous): + def _d(self, previous: PathSegment) -> str: cmd = "M" x = self.end.real y = self.end.imag @@ -731,16 +796,16 @@ def _d(self, previous): y -= previous.end.imag return f"{cmd} {x:G},{y:G}" - def point(self, pos): + def point(self, pos: float) -> complex: return self.start - def tangent(self, pos): + def tangent(self, pos: float) -> complex: return 0 - def length(self, error=ERROR, min_depth=MIN_DEPTH): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: return 0 - def boundingbox(self): + def boundingbox(self) -> List[float]: x_min = min(self.start.real, self.end.real) x_max = max(self.start.real, self.end.real) y_min = min(self.start.imag, self.end.imag) @@ -751,18 +816,18 @@ def boundingbox(self): class Close(Linear): """Represents the closepath command""" - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Close): return NotImplemented return self.start == other.start and self.end == other.end - def __repr__(self): + def __repr__(self) -> str: return f"Close(start={self.start}, end={self.end})" - def _d(self, previous): + def _d(self, previous: PathSegment) -> str: return "z" if self.relative else "Z" - def boundingbox(self): + def boundingbox(self) -> List[float]: x_min = min(self.start.real, self.end.real) x_max = max(self.start.real, self.end.real) y_min = min(self.start.imag, self.end.imag) @@ -770,43 +835,46 @@ def boundingbox(self): return [x_min, y_min, x_max, y_max] -class Path(MutableSequence): +class Path(MutableSequence[Union[PathSegment, Move]]): """A Path is a sequence of path segments""" - def __init__(self, *segments): + def __init__(self, *segments: Union[PathSegment, Move]) -> None: self._segments = list(segments) - self._length = None - self._lengths = None + self._length: Union[float, None] = None + self._lengths: Union[List[float], None] = None # Fractional distance from starting point through the end of each segment. - self._fractions = [] + self._fractions: List[float] = [] - def __getitem__(self, index): + # TODO: Missing handling for slices. + def __getitem__(self, index: int) -> Union[PathSegment, Move]: # type: ignore[override] return self._segments[index] - def __setitem__(self, index, value): + # TODO: Missing handling for slices. + def __setitem__(self, index: int, value: Union[PathSegment, Move]) -> None: # type: ignore[override] self._segments[index] = value self._length = None - def __delitem__(self, index): + # TODO: Missing handling for slices. + def __delitem__(self, index: int) -> None: # type: ignore[override] del self._segments[index] self._length = None - def insert(self, index, value): + def insert(self, index: int, value: Union[PathSegment, Move]) -> None: self._segments.insert(index, value) self._length = None - def reverse(self): + def reverse(self) -> None: # Reversing the order of a path would require reversing each element # as well. That's not implemented. raise NotImplementedError - def __len__(self): + def __len__(self) -> int: return len(self._segments) - def __repr__(self): + def __repr__(self) -> str: return "Path(%s)" % (", ".join(repr(x) for x in self._segments)) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Path): return NotImplemented if len(self) != len(other): @@ -816,12 +884,12 @@ def __eq__(self, other): return False return True - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Path): return NotImplemented return not self == other - def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH): + def _calc_lengths(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> None: if self._length is not None: return @@ -834,12 +902,14 @@ def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH): else: self._lengths = [each / self._length for each in lengths] # Calculate the fractional distance for each segment to use in point() - fraction = 0 + fraction = 0.0 for each in self._lengths: fraction += each self._fractions.append(fraction) - def _find_segment(self, pos, error=ERROR): + def _find_segment( + self, pos: float, error: float = ERROR + ) -> Tuple[Union[PathSegment, Move], float]: # Shortcuts if pos == 0.0: return self._segments[0], pos @@ -862,29 +932,36 @@ def _find_segment(self, pos, error=ERROR): ) return self._segments[i], segment_pos - def point(self, pos, error=ERROR): + def point(self, pos: float, error: float = ERROR) -> complex: segment, pos = self._find_segment(pos, error) return segment.point(pos) - def tangent(self, pos, error=ERROR): + def tangent(self, pos: float, error: float = ERROR) -> complex: segment, pos = self._find_segment(pos, error) return segment.tangent(pos) - def length(self, error=ERROR, min_depth=MIN_DEPTH): + def length(self, error: float = ERROR, min_depth: int = MIN_DEPTH) -> float: self._calc_lengths(error, min_depth) - return self._length + # TODO: refactor code to avoid this mypy error: + # Incompatible return value type (got "Optional[float]", expected "float") + return self._length # type: ignore[return-value] - def d(self): + def d(self) -> str: parts = [] previous_segment = None for segment in self: - parts.append(segment._d(previous_segment)) + # TODO: The `previous` argument in PathSegment._d(previous) cannot be + # None because it will crash at least in some cases like Line._d(None). + # mypy error: + # Argument 1 to "_d" of "PathSegment" has incompatible type "None"; + # expected "PathSegment" [arg-type] + parts.append(segment._d(previous_segment)) # type: ignore[arg-type] previous_segment = segment return " ".join(parts) - def boundingbox(self): + def boundingbox(self) -> List[float]: x_coords = [] y_coords = [] diff --git a/src/svg/path/py.typed b/src/svg/path/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_boundingbox_image.py b/tests/test_boundingbox_image.py index 21f19a7..c46163b 100644 --- a/tests/test_boundingbox_image.py +++ b/tests/test_boundingbox_image.py @@ -1,24 +1,25 @@ +from typing import Tuple import unittest import os import pytest import sys from PIL import Image, ImageDraw, ImageColor, ImageChops -from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc +from svg.path import CubicBezier, QuadraticBezier, Line, Arc, PathSegment -RED = ImageColor.getcolor("red", mode="RGB") -GREEN = ImageColor.getcolor("limegreen", mode="RGB") -BLUE = ImageColor.getcolor("cornflowerblue", mode="RGB") -YELLOW = ImageColor.getcolor("yellow", mode="RGB") -CYAN = ImageColor.getcolor("cyan", mode="RGB") -WHITE = ImageColor.getcolor("white", mode="RGB") -BLACK = ImageColor.getcolor("black", mode="RGB") +RED = ImageColor.getrgb("red") +GREEN = ImageColor.getrgb("limegreen") +BLUE = ImageColor.getrgb("cornflowerblue") +YELLOW = ImageColor.getrgb("yellow") +CYAN = ImageColor.getrgb("cyan") +WHITE = ImageColor.getrgb("white") +BLACK = ImageColor.getrgb("black") DOT = 4 + 4j # x+y radius of dot -def c2t(c): +def c2t(c: complex) -> Tuple[float, float]: """Make a complex number into a tuple""" return c.real, c.imag @@ -26,11 +27,11 @@ def c2t(c): class BoundingBoxImageTest(unittest.TestCase): """Creates a PNG image and compares with a correct PNG to test boundingbox capability""" - def setUp(self): + def setUp(self) -> None: self.image = Image.new(mode="RGB", size=(500, 1200)) self.draw = ImageDraw.Draw(self.image) - def draw_path(self, path): + def draw_path(self, path: PathSegment) -> None: lines = [c2t(path.point(x * 0.01)) for x in range(1, 101)] self.draw.line(lines, fill=WHITE, width=2) @@ -39,7 +40,7 @@ def draw_path(self, path): p = path.point(1) self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=GREEN) - def draw_boundingbox(self, path): + def draw_boundingbox(self, path: PathSegment) -> None: x1, y1, x2, y2 = path.boundingbox() self.draw.line( [ @@ -56,7 +57,7 @@ def draw_boundingbox(self, path): @pytest.mark.skipif( sys.platform != "linux", reason="Different platforms have different fonts" ) - def test_image(self): + def test_image(self) -> None: self.draw.text((10, 10), "This is an SVG line:") self.draw.text( (10, 100), diff --git a/tests/test_doc.py b/tests/test_doc.py index af37ebd..9e84266 100644 --- a/tests/test_doc.py +++ b/tests/test_doc.py @@ -1,5 +1,5 @@ import doctest -def test_readme(): +def test_readme() -> None: doctest.testfile("../README.rst") diff --git a/tests/test_generation.py b/tests/test_generation.py index 8d68ef0..ef4c8ed 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -1,9 +1,9 @@ import unittest -from svg.path.parser import parse_path +from svg.path import parse_path class TestGeneration(unittest.TestCase): - def test_svg_examples(self): + def test_svg_examples(self) -> None: """Examples from the SVG spec""" paths = [ # "M 100,100 L 300,100 L 200,300 Z", diff --git a/tests/test_image.py b/tests/test_image.py index 49967ea..0e4666b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,39 +1,40 @@ import unittest import os +from typing import Tuple from PIL import Image, ImageDraw, ImageColor, ImageChops from math import sqrt -from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc +from svg.path import CubicBezier, QuadraticBezier, Line, Arc, PathSegment -RED = ImageColor.getcolor("red", mode="RGB") -GREEN = ImageColor.getcolor("limegreen", mode="RGB") -BLUE = ImageColor.getcolor("cornflowerblue", mode="RGB") -YELLOW = ImageColor.getcolor("yellow", mode="RGB") -CYAN = ImageColor.getcolor("cyan", mode="RGB") -WHITE = ImageColor.getcolor("white", mode="RGB") -BLACK = ImageColor.getcolor("black", mode="RGB") +RED = ImageColor.getrgb("red") +GREEN = ImageColor.getrgb("limegreen") +BLUE = ImageColor.getrgb("cornflowerblue") +YELLOW = ImageColor.getrgb("yellow") +CYAN = ImageColor.getrgb("cyan") +WHITE = ImageColor.getrgb("white") +BLACK = ImageColor.getrgb("black") DOT = 4 + 4j # x+y radius of dot -def c2t(c): +def c2t(c: complex) -> Tuple[float, float]: """Make a complex number into a tuple""" return c.real, c.imag -def magnitude(c): +def magnitude(c: complex) -> float: return sqrt(c.real**2 + c.imag**2) class ImageTest(unittest.TestCase): """Creates a PNG image and compares with a correct PNG""" - def setUp(self): + def setUp(self) -> None: self.image = Image.new(mode="RGB", size=(500, 1200)) self.draw = ImageDraw.Draw(self.image) - def draw_path(self, path): + def draw_path(self, path: PathSegment) -> None: lines = [c2t(path.point(x * 0.01)) for x in range(1, 101)] self.draw.line(lines, fill=WHITE, width=2) @@ -42,7 +43,7 @@ def draw_path(self, path): p = path.point(1) self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=GREEN) - def draw_tangents(self, path, count): + def draw_tangents(self, path: PathSegment, count: int) -> None: count += 1 for i in range(1, count): p = path.point(i / count) @@ -56,7 +57,7 @@ def draw_tangents(self, path, count): self.draw.line([c2t(p), c2t(tt + p)], fill=YELLOW, width=1) - def test_image(self): + def test_image(self) -> None: self.draw.text((10, 10), "This is an SVG line:") self.draw.text( (10, 100), diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 723dd34..4f61359 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1,12 +1,12 @@ import unittest -from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc, Path, Move, Close -from svg.path.parser import parse_path +from svg.path import CubicBezier, QuadraticBezier, Line, Arc, Path, Move, Close +from svg.path import parse_path class TestParser(unittest.TestCase): maxDiff = None - def test_svg_examples(self): + def test_svg_examples(self) -> None: """Examples from the SVG spec""" path1 = parse_path("M 100 100 L 300 100 L 200 300 z") self.assertEqual( @@ -179,7 +179,7 @@ def test_svg_examples(self): ), ) - def test_wc3_examples12(self): + def test_wc3_examples12(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -227,7 +227,7 @@ def test_wc3_examples12(self): ), ) - def test_wc3_examples13(self): + def test_wc3_examples13(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -262,7 +262,7 @@ def test_wc3_examples13(self): ), ) - def test_wc3_examples14(self): + def test_wc3_examples14(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -318,7 +318,7 @@ def test_wc3_examples14(self): ), ) - def test_wc3_examples15(self): + def test_wc3_examples15(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -367,7 +367,7 @@ def test_wc3_examples15(self): ), ) - def test_wc3_examples17(self): + def test_wc3_examples17(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -380,7 +380,7 @@ def test_wc3_examples17(self): path17b = parse_path("M 250 50 L 250 150 L 350 150 L 350 50 z") self.assertEqual(path17a, path17b) - def test_wc3_examples18(self): + def test_wc3_examples18(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -421,7 +421,7 @@ def test_wc3_examples18(self): path18b = parse_path("M 20 160 H 40#90") self.assertEqual(path18a, path18b) - def test_wc3_examples19(self): + def test_wc3_examples19(self) -> None: """ W3C_SVG_11_TestSuite Paths @@ -476,7 +476,7 @@ def test_wc3_examples19(self): path19b = parse_path("M400,300 a25 25 0 0 0 25 -50 25 25 0 0 0 -25 50") self.assertEqual(path19a, path19b) - def test_wc3_examples20(self): + def test_wc3_examples20(self) -> None: """ W3C_SVG_11_TestSuite Paths Tests parsing of the elliptical arc path syntax. @@ -488,26 +488,20 @@ def test_wc3_examples20(self): path20b = parse_path("M200,120 h-25 a25,25 0 1125,25 z") self.assertEqual(path20a, path20b) path20a = parse_path("M280,120 h25 a25,25 0 1,0 -25,25 z") - self.assertRaises(Exception, 'parse_path("M280,120 h25 a25,25 0 6 0 -25,25 z")') + self.assertRaises(Exception, parse_path, "M280,120 h25 a25,25 0 6 0 -25,25 z") path20a = parse_path("M360,120 h-25 a25,25 0 1,1 25,25 z") - self.assertRaises( - Exception, 'parse_path("M360,120 h-25 a25,25 0 1 -1 25,25 z")' - ) + self.assertRaises(Exception, parse_path, "M360,120 h-25 a25,25 0 1 -1 25,25 z") path20a = parse_path("M120,200 h25 a25,25 0 1,1 -25,-25 z") path20b = parse_path("M120,200 h25 a25,25 0 1 1-25,-25 z") self.assertEqual(path20a, path20b) path20a = parse_path("M200,200 h-25 a25,25 0 1,0 25,-25 z") - self.assertRaises(Exception, 'parse_path("M200,200 h-25 a25,2501 025,-25 z")') + self.assertRaises(Exception, parse_path, "M200,200 h-25 a25,2501 025,-25 z") path20a = parse_path("M280,200 h25 a25,25 0 1,1 -25,-25 z") - self.assertRaises( - Exception, 'parse_path("M280,200 h25 a25 25 0 1 7 -25 -25 z")' - ) + self.assertRaises(Exception, parse_path, "M280,200 h25 a25 25 0 1 7 -25 -25 z") path20a = parse_path("M360,200 h-25 a25,25 0 1,0 25,-25 z") - self.assertRaises( - Exception, 'parse_path("M360,200 h-25 a25,25 0 -1 0 25,-25 z")' - ) + self.assertRaises(Exception, parse_path, "M360,200 h-25 a25,25 0 -1 0 25,-25 z") - def test_others(self): + def test_others(self) -> None: # Other paths that need testing: # Relative moveto: @@ -548,13 +542,13 @@ def test_others(self): Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j)), ) - def test_negative(self): + def test_negative(self) -> None: """You don't need spaces before a minus-sign""" path1 = parse_path("M100,200c10-5,20-10,30-20") path2 = parse_path("M 100 200 c 10 -5 20 -10 30 -20") self.assertEqual(path1, path2) - def test_numbers(self): + def test_numbers(self) -> None: """Exponents and other number format cases""" # It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported. path1 = parse_path("M-3.4e38 3.4E+38L-3.4E-38,3.4e-38") @@ -563,17 +557,17 @@ def test_numbers(self): ) self.assertEqual(path1, path2) - def test_errors(self): + def test_errors(self) -> None: self.assertRaises(ValueError, parse_path, "M 100 100 L 200 200 Z 100 200") - def test_non_path(self): + def test_non_path(self) -> None: # It's possible in SVG to create paths that has zero length, # we need to handle that. path = parse_path("M10.236,100.184") self.assertEqual(path.d(), "M 10.236,100.184") - def test_issue_45(self): + def test_issue_45(self) -> None: # A missing Z in certain cases path = parse_path( "m 1672.2372,-54.8161 " @@ -600,7 +594,7 @@ def test_issue_45(self): path.d(), ) - def test_arc_flag(self): + def test_arc_flag(self) -> None: """Issue #69""" path = parse_path( "M 5 1 v 7.344 A 3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5 C 0 13.421 1.579 15 3.5 15 " @@ -612,7 +606,7 @@ def test_arc_flag(self): # It ends on a vertical line to Y 1: self.assertEqual(path[-1].end.imag, 1) - def test_incomplete_numbers(self): + def test_incomplete_numbers(self) -> None: path = parse_path("M 0. .1") self.assertEqual(path.d(), "M 0,0.1") diff --git a/tests/test_paths.py b/tests/test_paths.py index cc16d2e..a6fd83e 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -1,8 +1,8 @@ import unittest from math import sqrt, pi -from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path -from svg.path.parser import parse_path +from svg.path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path +from svg.path import parse_path # Most of these test points are not calculated separately, as that would @@ -35,7 +35,7 @@ class LineTest(unittest.TestCase): - def test_lines(self): + def test_lines(self) -> None: # These points are calculated, and not just regression tests. line1 = Line(0j, 400 + 0j) @@ -62,7 +62,7 @@ def test_lines(self): self.assertAlmostEqual(line3.point(1), (0j)) self.assertAlmostEqual(line3.length(), 500) - def test_equality(self): + def test_equality(self) -> None: # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual line = Line(0j, 400 + 0j) @@ -76,7 +76,7 @@ def test_equality(self): class CubicBezierTest(unittest.TestCase): - def test_approx_circle(self): + def test_approx_circle(self) -> None: """This is a approximate circle drawn in Inkscape""" arc1 = CubicBezier( @@ -155,7 +155,7 @@ def test_approx_circle(self): self.assertAlmostEqual(arc4.point(0.9), (-2.59896457 - 32.20931675j)) self.assertAlmostEqual(arc4.point(1), (0j)) - def test_svg_examples(self): + def test_svg_examples(self) -> None: # M100,200 C100,100 250,100 250,200 path1 = CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j) self.assertAlmostEqual(path1.point(0), (100 + 200j)) @@ -229,7 +229,7 @@ def test_svg_examples(self): self.assertAlmostEqual(path9.point(0.9), (890.4 + 827j)) self.assertAlmostEqual(path9.point(1), (900 + 800j)) - def test_length(self): + def test_length(self) -> None: # A straight line: arc = CubicBezier( complex(0, 0), complex(0, 0), complex(0, 100), complex(0, 100) @@ -272,7 +272,7 @@ def test_length(self): ) self.assertTrue(arc.length() > 300.0) - def test_equality(self): + def test_equality(self) -> None: # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = CubicBezier( @@ -287,7 +287,7 @@ def test_equality(self): ) self.assertTrue(segment != Line(0, 400)) - def test_smooth(self): + def test_smooth(self) -> None: cb1 = CubicBezier(0, 0, 100 + 100j, 100 + 100j) cb2 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) self.assertFalse(cb2.is_smooth_from(cb1)) @@ -296,7 +296,7 @@ def test_smooth(self): class QuadraticBezierTest(unittest.TestCase): - def test_svg_examples(self): + def test_svg_examples(self) -> None: """These is the path in the SVG specs""" # M200,300 Q400,50 600,300 T1000,300 path1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) @@ -315,7 +315,7 @@ def test_svg_examples(self): self.assertAlmostEqual(path2.point(0.9), (960 + 345j)) self.assertAlmostEqual(path2.point(1), (1000 + 300j)) - def test_length(self): + def test_length(self) -> None: # expected results calculated with # svg.path.segment_length(q, 0, 1, q.start, q.end, 1e-14, 20, 0) q1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) @@ -335,7 +335,7 @@ def test_length(self): for q, exp_res in tests: self.assertAlmostEqual(q.length(), exp_res) - def test_equality(self): + def test_equality(self) -> None: # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) @@ -344,7 +344,7 @@ def test_equality(self): self.assertFalse(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) self.assertTrue(Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) != segment) - def test_linear_arcs_issue_61(self): + def test_linear_arcs_issue_61(self) -> None: p = parse_path("M 206.5,525 Q 162.5,583 162.5,583") self.assertAlmostEqual(p.length(), 72.80109889280519) p = parse_path("M 425.781 446.289 Q 410.40000000000003 373.047 410.4 373.047") @@ -366,7 +366,7 @@ def test_linear_arcs_issue_61(self): p = parse_path("M 615.297 470.503 Q 538.797 694.5029999999999 538.797 694.503") self.assertAlmostEqual(p.length(), 236.70287281737836) - def test_smooth(self): + def test_smooth(self) -> None: cb1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) cb2 = QuadraticBezier(600 + 300j, 400 + 50j, 1000 + 300j) self.assertFalse(cb2.is_smooth_from(cb1)) @@ -375,7 +375,7 @@ def test_smooth(self): class ArcTest(unittest.TestCase): - def test_points(self): + def test_points(self) -> None: arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) self.assertAlmostEqual(arc1.center, 100 + 0j) self.assertAlmostEqual(arc1.theta, 180.0) @@ -444,14 +444,14 @@ def test_points(self): self.assertAlmostEqual(arc4.point(0.9), (145.399049974 + 44.5503262094j)) self.assertAlmostEqual(arc4.point(1.0), (100 + 50j)) - def test_length(self): + def test_length(self) -> None: # I'll test the length calculations by making a circle, in two parts. arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) self.assertAlmostEqual(arc1.length(), pi * 100) self.assertAlmostEqual(arc2.length(), pi * 100) - def test_length_out_of_range(self): + def test_length_out_of_range(self) -> None: # See F.6.2 Out-of-range parameters # If the endpoints (x1, y1) and (x2, y2) are identical, then this is @@ -480,14 +480,14 @@ def test_length_out_of_range(self): arc = Arc(200 + 0j, -100 - 100j, 720, 0, 0, 0j) self.assertAlmostEqual(arc.length(), pi * 100) - def test_equality(self): + def test_equality(self) -> None: # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) self.assertTrue(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) self.assertTrue(segment != Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j)) - def test_issue25(self): + def test_issue25(self) -> None: # This raised a math domain error Arc( (725.307482225571 - 915.5548199281527j), @@ -500,7 +500,7 @@ def test_issue25(self): class TestPath(unittest.TestCase): - def test_circle(self): + def test_circle(self) -> None: arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) path = Path(arc1, arc2) @@ -511,7 +511,7 @@ def test_circle(self): self.assertAlmostEqual(path.point(1.0), (0j)) self.assertAlmostEqual(path.length(), pi * 200) - def test_svg_specs(self): + def test_svg_specs(self) -> None: """The paths that are in the SVG specs""" # Big pie: M300,200 h-150 a150,150 0 1,0 150,-150 z @@ -571,7 +571,7 @@ def test_svg_specs(self): self.assertAlmostEqual(path.point(1.0), (1050 + 125j)) self.assertAlmostEqual(path.length(), 928.388639381) - def test_repr(self): + def test_repr(self) -> None: path = Path( Line(start=600 + 350j, end=650 + 325j), Arc( @@ -592,11 +592,11 @@ def test_repr(self): ) self.assertEqual(eval(repr(path)), path) - def test_reverse(self): + def test_reverse(self) -> None: # Currently you can't reverse paths. self.assertRaises(NotImplementedError, Path().reverse) - def test_equality(self): + def test_equality(self) -> None: # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual path1 = Path( @@ -650,16 +650,17 @@ def test_equality(self): self.assertFalse(path1 == path2) # It's not equal to a list of it's segments - self.assertTrue(path1 != path1[:]) - self.assertFalse(path1 == path1[:]) + # TODO: Path does not support slicing, but this is not tested here. + self.assertTrue(path1 != path1[:]) # type: ignore[index] + self.assertFalse(path1 == path1[:]) # type: ignore[index] - def test_non_arc(self): + def test_non_arc(self) -> None: # And arc with the same start and end is a noop. segment = Arc(0j + 70j, 35 + 35j, 0, 1, 0, 0 + 70j) self.assertEqual(segment.length(), 0) self.assertEqual(segment.point(0.5), segment.start) - def test_zero_paths(self): + def test_zero_paths(self) -> None: move_only = Path(Move(0)) self.assertEqual(move_only.point(0), 0 + 0j) self.assertEqual(move_only.point(0.5), 0 + 0j) @@ -684,7 +685,7 @@ def test_zero_paths(self): self.assertEqual(only_line.point(1), 1 + 1j) self.assertEqual(only_line.length(), 0) - def test_tangent(self): + def test_tangent(self) -> None: path = Path( Line(start=600 + 350j, end=650 + 325j), Arc( @@ -715,7 +716,7 @@ def test_tangent(self): self.assertAlmostEqual(path.tangent(0.75), 13.630819414210208j) self.assertAlmostEqual(path.tangent(1), 600j) - def test_tangent_magnitude(self): + def test_tangent_magnitude(self) -> None: line1 = Line(start=6 + 3.5j, end=6.5 + 3.25j) line2 = Line(start=6 + 3.5j, end=7 + 3j) # line2 is twice as long as line1, the tangent should have twice the magnitude: diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py index 11df348..22a317f 100644 --- a/tests/test_tokenizer.py +++ b/tests/test_tokenizer.py @@ -1,3 +1,4 @@ +from typing import Union, List, Tuple import pytest from svg.path import parser @@ -62,11 +63,21 @@ @pytest.mark.parametrize("path, commands, tokens", PATHS) -def test_commandifier(path, commands, tokens): +def test_commandifier( + path: str, + commands: List[Tuple[str, ...]], + tokens: List[Tuple[Union[str, complex, float, bool, None], ...]], +) -> None: assert list(parser._commandify_path(path)) == commands assert list(parser._tokenize_path(path)) == tokens @pytest.mark.parametrize("path, commands, tokens", PATHS) -def test_parser(path, commands, tokens): - path = parser.parse_path(path) +def test_parser( + path: str, + commands: List[Tuple[str, ...]], + tokens: List[Tuple[Union[str, complex, float, bool, None], ...]], +) -> None: + # TODO: Add a check that svg_path.d() is correct. + # flake8: F841 local variable 'svg_path' is assigned to but never used + svg_path = parser.parse_path(path) # noqa: F841 From d8f170b2ef3574afbe66d14ec3e4092932c7eeef Mon Sep 17 00:00:00 2001 From: udifuchs Date: Fri, 1 Dec 2023 21:37:17 -0600 Subject: [PATCH 2/2] Fix tests for python 3.8 and 3.11. --- src/svg/path/path.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/svg/path/path.py b/src/svg/path/path.py index 4c88f36..d5d1260 100644 --- a/src/svg/path/path.py +++ b/src/svg/path/path.py @@ -1,14 +1,11 @@ from __future__ import annotations from math import sqrt, cos, sin, acos, degrees, radians, log, pi -from typing import List, Tuple, Union +from typing import List, Tuple, Union, TYPE_CHECKING from bisect import bisect from abc import ABC, abstractmethod import math -try: - from collections.abc import MutableSequence -except ImportError: - from collections import MutableSequence +from collections.abc import MutableSequence # This file contains classes for the different types of SVG path segments as # well as a Path object that contains a sequence of path segments. @@ -835,7 +832,18 @@ def boundingbox(self) -> List[float]: return [x_min, y_min, x_max, y_max] -class Path(MutableSequence[Union[PathSegment, Move]]): +if TYPE_CHECKING: + + class PathType(MutableSequence[Union[PathSegment, Move]]): + pass + +else: + + class PathType(MutableSequence): + pass + + +class Path(PathType): """A Path is a sequence of path segments""" def __init__(self, *segments: Union[PathSegment, Move]) -> None: