Repository: googlefonts/picosvg

Issue:
circular dependency
We [me I think] have managed to setup a dependency cyle that causes importing modules to be order-dependent. This also causes pytest to work for some files or sets of files and fail for others. This slipped by because the complete pytest everything run succeeds.

**sad**

```
python
Python 3.7.7 (default, Mar 10 2020, 15:43:03)
[Clang 11.0.0 (clang-1100.0.33.17)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from picosvg import svg_types
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "src/picosvg/svg_types.py", line 19, in <module>
    from picosvg import svg_pathops
  File "src/picosvg/svg_pathops.py", line 19, in <module>
    from picosvg.svg_types import SVGPath, SVGShape
ImportError: cannot import name 'SVGPath' from 'picosvg.svg_types' (src/picosvg/svg_types.py)
```

**glad**

```
python
Python 3.7.7 (default, Mar 10 2020, 15:43:03)
[Clang 11.0.0 (clang-1100.0.33.17)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from picosvg import svg_pathops
>>> from picosvg import svg_types
>>> 
```

Fix the cycle so this type of nonsense doesn't occur.

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 0d89dc9..f174887 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -24,6 +24,8 @@ install:
   - pip install -e .
 script:
   - pytest -vv
+  # detect issues where tests don't work except as a group
+  - (for f in tests/*_test.py; do pytest -vv $f || exit; done)
 deploy:
   provider: pypi
   user: "__token__"
diff --git a/src/picosvg/svg.py b/src/picosvg/svg.py
index 3a7c994..2593476 100644
--- a/src/picosvg/svg.py
+++ b/src/picosvg/svg.py
@@ -277,7 +277,9 @@ class SVG:
 
         # union all the shapes under the clipPath
         # Fails if there are any non-shapes under clipPath
-        clip_path = svg_pathops.union(*[from_element(e) for e in clip_path_el])
+        clip_path = SVGPath.from_commands(
+            svg_pathops.union(*[from_element(e).as_cmd_seq() for e in clip_path_el])
+        )
         return clip_path
 
     def _combine_clip_paths(self, clip_paths) -> SVGPath:
@@ -286,7 +288,9 @@ class SVG:
             raise ValueError("Cannot combine no clip_paths")
         if len(clip_paths) == 1:
             return clip_paths[0]
-        return svg_pathops.intersection(*clip_paths)
+        return SVGPath.from_commands(
+            svg_pathops.intersection(*[c.as_cmd_seq() for c in clip_paths])
+        )
 
     def _new_id(self, tag, template):
         for i in range(100):
@@ -448,7 +452,16 @@ class SVG:
             return (shape,)
 
         # make a new path that is the stroke
-        stroke = svg_pathops.stroke(shape, self.tolerance)
+        stroke = SVGPath.from_commands(
+            svg_pathops.stroke(
+                shape.as_cmd_seq(),
+                shape.stroke_linecap,
+                shape.stroke_linejoin,
+                shape.stroke_width,
+                shape.stroke_miterlimit,
+                self.tolerance,
+            )
+        )
 
         # convert some stroke attrs (e.g. stroke => fill)
         for field in dataclasses.fields(shape):
@@ -515,7 +528,9 @@ class SVG:
             el, (target,) = self.elements[el_idx]
             target = target.as_path().absolute(inplace=True)
 
-            target.d = svg_pathops.intersection(target, clip_path).d
+            target.d = SVGPath.from_commands(
+                svg_pathops.intersection(target.as_cmd_seq(), clip_path.as_cmd_seq())
+            ).d
             target.clip_path = ""
             self._set_element(el_idx, el, (target,))
 
diff --git a/src/picosvg/svg_meta.py b/src/picosvg/svg_meta.py
index 05d063c..82d8bfb 100644
--- a/src/picosvg/svg_meta.py
+++ b/src/picosvg/svg_meta.py
@@ -13,6 +13,12 @@
 # limitations under the License.
 
 import re
+from typing import Generator, Iterable, Tuple
+
+
+SVGCommand = Tuple[str, Tuple[float, ...]]
+SVGCommandSeq = Iterable[SVGCommand]
+SVGCommandGen = Generator[SVGCommand, None, None]
 
 
 def svgns():
diff --git a/src/picosvg/svg_pathops.py b/src/picosvg/svg_pathops.py
index 8ed767d..25baac3 100644
--- a/src/picosvg/svg_pathops.py
+++ b/src/picosvg/svg_pathops.py
@@ -15,8 +15,9 @@
 """SVGPath <=> skia-pathops constructs to enable ops on paths."""
 import functools
 import pathops
+from typing import Sequence, Tuple
+from picosvg.svg_meta import SVGCommand, SVGCommandGen, SVGCommandSeq
 from picosvg.svg_transform import Affine2D
-from picosvg.svg_types import SVGPath, SVGShape
 
 
 # Absolutes coords assumed
@@ -44,15 +45,21 @@ _SVG_TO_SKIA_LINE_JOIN = {
 }
 
 
-def _simple_skia_to_svg(svg_cmd, svg_path, points):
+def _simple_skia_to_svg(svg_cmd, points) -> SVGCommandGen:
     # pathops.Path gives us sequences of points, flatten 'em
-    cmd_args = tuple(c for pt in points for c in pt)
-    svg_path._add_cmd(svg_cmd, *cmd_args)
+    yield (svg_cmd, tuple(c for pt in points for c in pt))
 
 
-def _qcurveto_to_svg(svg_path, points):
+def _qcurveto_to_svg(points) -> SVGCommandGen:
     for (control_pt, end_pt) in pathops.decompose_quadratic_segment(points):
-        svg_path._add_cmd("Q", *control_pt, *end_pt)
+        yield ("Q", control_pt + end_pt)
+
+
+def _end_path(points) -> SVGCommandGen:
+    if points:
+        raise ValueError("endPath should have no points")
+    return  # pytype: disable=bad-return-type
+    yield
 
 
 _SKIA_CMD_TO_SVG_CMD = {
@@ -65,77 +72,73 @@ _SKIA_CMD_TO_SVG_CMD = {
     # more interesting conversions
     "qCurveTo": _qcurveto_to_svg,
     # nop
-    "endPath": lambda *_: None,
+    "endPath": _end_path,
 }
 
 
-def skia_path(shape: SVGShape):
-    path = (
-        shape.as_path()
-        .explicit_lines()  # hHvV => lL
-        .expand_shorthand(inplace=True)
-        .absolute(inplace=True)
-        .arcs_to_cubics(inplace=True)
-    )
-
+def skia_path(svg_cmds: SVGCommandSeq):
     sk_path = pathops.Path()
-    for cmd, args in path:
+    for cmd, args in svg_cmds:
         if cmd not in _SVG_CMD_TO_SKIA_FN:
             raise ValueError(f'No mapping to Skia for "{cmd} {args}"')
         _SVG_CMD_TO_SKIA_FN[cmd](sk_path, *args)
-
     return sk_path
 
 
-def svg_path(skia_path: pathops.Path) -> SVGPath:
-    svg_path = SVGPath()
+def svg_commands(skia_path: pathops.Path) -> SVGCommandGen:
     for cmd, points in skia_path.segments:
         if cmd not in _SKIA_CMD_TO_SVG_CMD:
             raise ValueError(f'No mapping to svg for "{cmd} {points}"')
-        _SKIA_CMD_TO_SVG_CMD[cmd](svg_path, points)
-    return svg_path
-
+        for svg_cmd, svg_args in _SKIA_CMD_TO_SVG_CMD[cmd](points):
+            yield (svg_cmd, svg_args)
 
-def _do_pathop(op, svg_shapes) -> SVGShape:
-    if not svg_shapes:
-        return SVGPath()
 
-    sk_path = skia_path(svg_shapes[0])
-    for svg_shape in svg_shapes[1:]:
-        sk_path2 = skia_path(svg_shape)
+def _do_pathop(op: str, svg_cmd_seqs: Sequence[SVGCommandSeq]) -> SVGCommandGen:
+    if not svg_cmd_seqs:
+        return  # pytype: disable=bad-return-type
+    sk_path = skia_path(svg_cmd_seqs[0])
+    for svg_cmds in svg_cmd_seqs[1:]:
+        sk_path2 = skia_path(svg_cmds)
         sk_path = pathops.op(sk_path, sk_path2, op)
-    return svg_path(sk_path)
+    return svg_commands(sk_path)
 
 
-def union(*svg_shapes) -> SVGShape:
-    return _do_pathop(pathops.PathOp.UNION, svg_shapes)
+def union(*svg_cmd_seqs: SVGCommandSeq) -> SVGCommandGen:
+    return _do_pathop(pathops.PathOp.UNION, svg_cmd_seqs)
 
 
-def intersection(*svg_shapes) -> SVGShape:
-    return _do_pathop(pathops.PathOp.INTERSECTION, svg_shapes)
+def intersection(*svg_cmd_seqs) -> SVGCommandGen:
+    return _do_pathop(pathops.PathOp.INTERSECTION, svg_cmd_seqs)
 
 
-def transform(svg_shape: SVGShape, affine: Affine2D) -> SVGShape:
-    sk_path = skia_path(svg_shape).transform(*affine)
-    return svg_path(sk_path)
+def transform(svg_cmds: SVGCommandSeq, affine: Affine2D) -> SVGCommandGen:
+    sk_path = skia_path(svg_cmds).transform(*affine)
+    return svg_commands(sk_path)
 
 
-def stroke(shape: SVGShape, tolerance: float) -> SVGShape:
+def stroke(
+    svg_cmds: SVGCommandSeq,
+    svg_linecap: str,
+    svg_linejoin: str,
+    stroke_width: float,
+    stroke_miterlimit: float,
+    tolerance: float,
+) -> SVGCommandGen:
     """Create a path that is shape with it's stroke applied."""
-    cap = _SVG_TO_SKIA_LINE_CAP.get(shape.stroke_linecap, None)
+    cap = _SVG_TO_SKIA_LINE_CAP.get(svg_linecap, None)
     if cap is None:
-        raise ValueError(f"Unsupported cap {shape.stroke_linecap}")
-    join = _SVG_TO_SKIA_LINE_JOIN.get(shape.stroke_linejoin, None)
+        raise ValueError(f"Unsupported cap {svg_linecap}")
+    join = _SVG_TO_SKIA_LINE_JOIN.get(svg_linejoin, None)
     if join is None:
-        raise ValueError(f"Unsupported join {shape.stroke_linejoin}")
-    sk_path = skia_path(shape)
-    sk_path.stroke(shape.stroke_width, cap, join, shape.stroke_miterlimit)
+        raise ValueError(f"Unsupported join {svg_linejoin}")
+    sk_path = skia_path(svg_cmds)
+    sk_path.stroke(stroke_width, cap, join, stroke_miterlimit)
 
     # nuke any conics that snuck in (e.g. with stroke-linecap="round")
     sk_path.convertConicsToQuads(tolerance)
 
-    return svg_path(sk_path)
+    return svg_commands(sk_path)
 
 
-def bounding_box(shape: SVGShape):
-    return skia_path(shape).bounds
+def bounding_box(svg_cmds: SVGCommandSeq):
+    return skia_path(svg_cmds).bounds
diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py
index 6f5ac68..bc434cd 100644
--- a/src/picosvg/svg_reuse.py
+++ b/src/picosvg/svg_reuse.py
@@ -22,7 +22,7 @@ from picosvg.svg_transform import Affine2D
 
 def _first_move(shape: SVGShape) -> Tuple[float, float]:
     cmd, args = next(iter(shape.as_path()))
-    if cmd != "M":
+    if cmd.upper() != "M":
         raise ValueError(f"Path for {shape} should start with a move")
     return args
 
diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py
index 0a91f22..779ec6d 100644
--- a/src/picosvg/svg_types.py
+++ b/src/picosvg/svg_types.py
@@ -20,6 +20,7 @@ from picosvg import svg_pathops
 from picosvg.arc_to_cubic import arc_to_cubic
 from picosvg.svg_path_iter import parse_svg_path
 from picosvg.svg_transform import Affine2D
+from typing import Generator
 
 
 # Subset of https://www.w3.org/TR/SVG11/painting.html
@@ -69,7 +70,7 @@ class SVGShape:
         self.opacity = opacity
         self.transform = transform
 
-    def visible(self):
+    def visible(self) -> bool:
         def _visible(fill, opacity):
             return fill != "none" and opacity != 0
             # we're ignoring fill-opacity
@@ -79,15 +80,25 @@ class SVGShape:
         )
 
     def bounding_box(self) -> Rect:
-        x1, y1, x2, y2 = svg_pathops.bounding_box(self)
+        x1, y1, x2, y2 = svg_pathops.bounding_box(self.as_cmd_seq())
         return Rect(x1, y1, x2 - x1, y2 - y1)
 
-    def apply_transform(self, transform: Affine2D):
-        return svg_pathops.transform(self, transform)
+    def apply_transform(self, transform: Affine2D) -> "SVGPath":
+        cmds = svg_pathops.transform(self.as_cmd_seq(), transform)
+        return SVGPath.from_commands(cmds)
 
     def as_path(self) -> "SVGPath":
         raise NotImplementedError("You should implement as_path")
 
+    def as_cmd_seq(self) -> svg_meta.SVGCommandSeq:
+        return (
+            self.as_path()
+            .explicit_lines()  # hHvV => lL
+            .expand_shorthand(inplace=True)
+            .absolute(inplace=True)
+            .arcs_to_cubics(inplace=True)
+        )
+
     def absolute(self, inplace=False) -> "SVGShape":
         """Returns equivalent path with only absolute commands."""
         # only meaningful for path, which overrides
@@ -95,9 +106,8 @@ class SVGShape:
 
 
 # https://www.w3.org/TR/SVG11/paths.html#PathElement
-# Iterable, returning each command in the path.
 @dataclasses.dataclass
-class SVGPath(SVGShape):
+class SVGPath(SVGShape, svg_meta.SVGCommandSeq):
     d: str = ""
 
     def __init__(self, **kwargs):
@@ -348,6 +358,15 @@ class SVGPath(SVGShape):
         target.walk(arc_to_cubic_callback)
         return target
 
+    @classmethod
+    def from_commands(
+        cls, svg_cmds: Generator[svg_meta.SVGCommand, None, None]
+    ) -> "SVGPath":
+        svg_path = cls()
+        for cmd, args in svg_cmds:
+            svg_path._add_cmd(cmd, *args)
+        return svg_path
+
 
 # https://www.w3.org/TR/SVG11/shapes.html#CircleElement
 @dataclasses.dataclass


Test patch:
diff --git a/tests/svg_pathops_test.py b/tests/svg_pathops_test.py
index b894fc9..e2dbf9f 100644
--- a/tests/svg_pathops_test.py
+++ b/tests/svg_pathops_test.py
@@ -58,12 +58,12 @@ def _round(pt, digits):
     ],
 )
 def test_skia_path_roundtrip(shape, expected_segments, expected_path):
-    skia_path = svg_pathops.skia_path(shape)
+    skia_path = svg_pathops.skia_path(shape.as_cmd_seq())
     rounded_segments = list(skia_path.segments)
     for idx, (cmd, points) in enumerate(rounded_segments):
         rounded_segments[idx] = (cmd, tuple(_round(pt, 3) for pt in points))
     assert tuple(rounded_segments) == expected_segments
-    assert svg_pathops.svg_path(skia_path).d == expected_path
+    assert SVGPath.from_commands(svg_pathops.svg_commands(skia_path)).d == expected_path
 
 
 @pytest.mark.parametrize(
@@ -80,7 +80,10 @@ def test_skia_path_roundtrip(shape, expected_segments, expected_path):
     ],
 )
 def test_pathops_union(shapes, expected_result):
-    assert svg_pathops.union(*shapes).d == expected_result
+    assert (
+        SVGPath.from_commands(svg_pathops.union(*[s.as_cmd_seq() for s in shapes])).d
+        == expected_result
+    )
 
 
 @pytest.mark.parametrize(
@@ -97,4 +100,9 @@ def test_pathops_union(shapes, expected_result):
     ],
 )
 def test_pathops_intersection(shapes, expected_result):
-    assert svg_pathops.intersection(*shapes).d == expected_result
+    assert (
+        SVGPath.from_commands(
+            svg_pathops.intersection(*[s.as_cmd_seq() for s in shapes])
+        ).d
+        == expected_result
+    )