diff options
| author | capellancitizen <thecapellancitizen@gmail.com> | 2025-03-09 21:21:48 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-09 21:21:48 -0400 |
| commit | 99509df8d8abf1e7b701a4a09cf170a362f6d878 (patch) | |
| tree | a461549502fa9f37dc287789b6c7db81dfcd5368 | |
| parent | 0d2fc24f25f87562f0755b53dad6204efad1330d (diff) | |
Mypy type correctness (#3199)
51 files changed, 577 insertions, 302 deletions
diff --git a/.github/mypy-github-formatter b/.github/mypy-github-formatter new file mode 100755 index 00000000..dfc8309a --- /dev/null +++ b/.github/mypy-github-formatter @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +# A helper script that you can pipe the output of `mypy --output json` into Github actions workflow message commands +# so they show up in the code review panel and job output as warnings. +# (https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#about-workflow-commands) + +import json + +had_errors = False + +while True: + try: + line = input() + except EOFError: + break + if len(line) == 0: + break + + had_errors = True + + line = json.loads(line) + + # Format the line into a github command that will show up in the code review panel, + # as well as a note about the file and line so that it's clear to readers of the build log where + # errors were found + + # Could use line["severity"] to change the message type, but for now we just + # want to use these to flag potential commit issues rather than reject commits outright. + github_out = '::warning title=Type Checking,file={file},line={line},col={column}::{message} [{code}]'.format(**line) + hint = line["hint"] + if hint is not None: + github_out += f". HINT: {hint}" + + print(github_out) + print('(in {file}:{line})'.format(**line)) + +if not had_errors: + print("No type errors found!")
\ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ecbffbb..de20b889 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,9 +66,16 @@ jobs: - shell: bash run: | bin/build-linux - - shell: bash + - name: Tests + shell: bash run: | pytest + - name: Mypy Type Checking + shell: bash + run: | + python -m pip install mypy + python -m mypy --output json | python .github/mypy-github-formatter + continue-on-error: true - shell: bash run: | make dist @@ -131,9 +138,16 @@ jobs: python -m pip install pyinstaller echo "${{ env.pythonLocation }}\bin" >> $GITHUB_PATH - - shell: bash + - name: Tests + shell: bash run: | pytest + - name: Mypy Type Checking + shell: bash + run: | + python -m pip install mypy + python -m mypy --output json | python .github/mypy-github-formatter + continue-on-error: true - shell: bash run: | make dist @@ -286,9 +300,16 @@ jobs: run: | SET BUILD32="1" bin\build-geos-win.cmd - - shell: bash + - name: Tests + shell: bash run: | pytest + - name: Mypy Type Checking + shell: bash + run: | + python -m pip install mypy + python -m mypy --output json | python .github/mypy-github-formatter + continue-on-error: true - shell: bash run: | make dist @@ -345,9 +366,16 @@ jobs: shell: cmd run: | bin\build-geos-win.cmd - - shell: bash + - name: Tests + shell: bash run: | pytest + - name: Mypy Type Checking + shell: bash + run: | + python -m pip install mypy + python -m mypy --output json | python .github/mypy-github-formatter + continue-on-error: true - shell: bash run: | make dist @@ -404,9 +432,16 @@ jobs: pip install pyinstaller echo "${{ env.pythonLocation }}/bin" >> $GITHUB_PATH - - shell: bash + - name: Tests + shell: bash run: | pytest + - name: Mypy Type Checking + shell: bash + run: | + python -m pip install mypy + python -m mypy --output json | python .github/mypy-github-formatter + continue-on-error: true - shell: bash run: | make dist @@ -466,9 +501,16 @@ jobs: pip install pyinstaller echo "${{ env.pythonLocation }}/bin" >> $GITHUB_PATH - - shell: bash + - name: Tests + shell: bash run: | pytest + - name: Mypy Type Checking + shell: bash + run: | + python -m pip install mypy + python -m mypy --output json | python .github/mypy-github-formatter + continue-on-error: true - shell: bash run: | make dist diff --git a/CODING_STYLE.md b/CODING_STYLE.md index 34fff7c4..39fa5244 100644 --- a/CODING_STYLE.md +++ b/CODING_STYLE.md @@ -31,6 +31,32 @@ if [ "$?" != "0" ]; then fi ``` +Type Annotations +================ + +We encourage the use of type annotations in Python code. +Type annotations make the code easier to read and understand, for example by making it clear what kinds of data functions accept as arguments and return. +Editors and IDEs can also read this type information to power features like autocomplete. +Type annotations also allow us to use the typechecker [Mypy](https://mypy.readthedocs.io/en/stable/#) to check for errors. + +A great reference for how to use annotations is the [Mypy cheat sheet](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html). +It covers most of the common uses and patterns. +[Mypy's Common Issues page](https://mypy.readthedocs.io/en/stable/common_issues.html) is also useful for understanding the common pitfalls with type-checking you may encounter. +Notably, Mypy does not perform type-checking on functions that don't have type annotations, but as we introduce new code with annotations we'll have better coverage as time goes on. + +You can run Mypy against your changes yourself simply by [installing Mypy](https://mypy.readthedocs.io/en/stable/getting_started.html#installing-and-running-mypy) running `mypy` in the project root. +The project's mypy.ini file sets all of the relevant configuration, so no other arguments are needed. +Mypy is also run as part of this project's builds on Github. +Errors that Mypy picks up won't cause your build to fail, but will appear on Pull Requests so both you and reviewers can see the potential issues. + +Much of our code, especially older code, lacks type annotations. +If you want to add type annotations to older code, or learn what types are used in a part of the codebase without type annotations, you my find [MonkeyType](https://monkeytype.readthedocs.io/en/stable/) useful. +You can easily have MonkeyType collect type information from Ink/Stitch in a similar way to how you can use one of several profilers with Ink/Stitch. +Simply copy `DEBUG_template.toml` to `DEBUG.toml`, and uncomment lines so that the `profiler_type = "monkeytype"` +and `profile_enable = True` options are set. +After running Ink/Stitch command, a window will pop up telling you how to run Monkeytype and use your newly-collected type information. +Multiple command runs will all add to the type information database. + Guidance and Comments ===================== diff --git a/DEBUG_template.toml b/DEBUG_template.toml index f446b98d..f33b17b7 100644 --- a/DEBUG_template.toml +++ b/DEBUG_template.toml @@ -45,6 +45,7 @@ # profiler_type = "cprofile" # profiler_type = "profile" # profiler_type = "pyinstrument" +# profiler_type = "monkeytype" ### enable profiler, see cmd line arg -p, default: false # profile_enable = true diff --git a/lib/commands.py b/lib/commands.py index c1ac35a0..abef3912 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -7,7 +7,7 @@ import os import sys from copy import deepcopy from random import random -from typing import List +from typing import List, Optional, cast import inkex from shapely import geometry as shgeo @@ -110,17 +110,17 @@ class BaseCommand(object): class Command(BaseCommand): - def __init__(self, connector): - self.connector: inkex.Path = connector + def __init__(self, connector: inkex.PathElement) -> None: + self.connector = connector self.svg = self.connector.getroottree().getroot() self.parse_command() - def parse_connector_path(self): + def parse_connector_path(self) -> inkex.Path: path = inkex.paths.Path(self.connector.get('d')).to_superpath() return apply_transforms(path, self.connector) - def parse_command(self): + def parse_command(self) -> None: path = self.parse_connector_path() if len(path) == 0: raise CommandParseError("connector has no path information") @@ -144,7 +144,7 @@ class Command(BaseCommand): self.target: inkex.BaseElement = neighbors[1] - pos = [float(self.use.get("x", 0)), float(self.use.get("y", 0))] + pos = (float(self.use.get("x", 0)), float(self.use.get("y", 0))) transform = get_node_transform(self.use) pos = inkex.Transform(transform).apply_to_point(pos) self.target_point = pos @@ -156,13 +156,16 @@ class Command(BaseCommand): """ Clone this command and point it to the new target, positioning it relative to the new target the same as the target """ - group: inkex.BaseElement = self.connector.getparent() - transform_relative_to_target = -self.target.composed_transform() @ group.composed_transform() + group: Optional[inkex.BaseElement] = cast(Optional[inkex.BaseElement], self.connector.getparent()) + assert group is not None, "The connector should be part of a group." + transform_relative_to_target: inkex.Transform = -self.target.composed_transform() @ group.composed_transform() # Clone group - cloned_group = copy_no_children(self.connector.getparent()) + cloned_group = copy_no_children(group) cloned_group.transform = new_target.transform @ transform_relative_to_target - new_target.getparent().append(cloned_group) + new_target_parent = new_target.getparent() + assert new_target_parent is not None, "The target should be a non-root element." + new_target_parent.append(cloned_group) symbol = copy_no_children(self.use) cloned_group.append(symbol) @@ -200,12 +203,11 @@ class StandaloneCommand(BaseCommand): @property @cache - def point(self): - pos = [float(self.node.get("x", 0)), float(self.node.get("y", 0))] + def point(self) -> Point: + pos = (float(self.node.get("x", 0)), float(self.node.get("y", 0))) transform = get_node_transform(self.node) - pos = inkex.transforms.Transform(transform).apply_to_point(pos) - return Point(*pos) + return Point(*inkex.transforms.Transform(transform).apply_to_point(pos)) def get_command_description(command: str) -> str: @@ -298,11 +300,11 @@ def _standalone_commands(svg): pass -def is_command(node): +def is_command(node: inkex.BaseElement) -> bool: return CONNECTION_START in node.attrib or CONNECTION_END in node.attrib -def is_command_symbol(node): +def is_command_symbol(node: inkex.BaseElement) -> bool: symbol = None xlink = node.get(XLINK_HREF, "") if xlink.startswith("#inkstitch_"): @@ -311,23 +313,23 @@ def is_command_symbol(node): @cache -def symbols_path(): +def symbols_path() -> str: return os.path.join(get_bundled_dir("symbols"), "inkstitch.svg") @cache -def symbols_svg(): +def symbols_svg() -> inkex.BaseElement: with open(symbols_path()) as symbols_file: return inkex.load_svg(symbols_file).getroot() @cache -def symbol_defs(): +def symbol_defs() -> inkex.BaseElement: return symbols_svg().defs @cache -def ensure_symbol(svg, command): +def ensure_symbol(svg, command) -> None: """Make sure the command's symbol definition exists in the <svg:defs> tag.""" # using @cache really just makes sure that we don't bother ensuring the diff --git a/lib/debug/debugger.py b/lib/debug/debugger.py index 11293c8a..c58ddb3e 100644 --- a/lib/debug/debugger.py +++ b/lib/debug/debugger.py @@ -125,6 +125,10 @@ # to see flask server url routes: # - comment out the line self.disable_logging() in run() of lib/api/server.py +# We have some ignores so you don't see errors if you don't have one or more of the debugger libraries installed. +# But in turn those ignores will cause unused-ignore errors if those libraries aren't installed... +# mypy: disable-error-code="unused-ignore" + import os import sys @@ -148,11 +152,11 @@ def init_debugger(debug_type:str, ini: dict): try: if debugger == 'vscode': - import debugpy + import debugpy # type: ignore[import-untyped, import-not-found] elif debugger == 'pycharm': - import pydevd_pycharm + import pydevd_pycharm # type: ignore[import-untyped, import-not-found] elif debugger == 'pydev': - import pydevd + import pydevd # type: ignore[import-untyped, import-not-found] elif debugger == 'file': pass else: diff --git a/lib/debug/logging.py b/lib/debug/logging.py index fa474348..c46140ec 100644 --- a/lib/debug/logging.py +++ b/lib/debug/logging.py @@ -69,6 +69,7 @@ import os import sys from pathlib import Path +from typing import Dict, Any if sys.version_info >= (3, 11): import tomllib # built-in in Python 3.11+ @@ -139,7 +140,7 @@ def disable_warnings(): # in development mode we want to use configuration from some LOGGING.toml file def activate_for_development(ini: dict, SCRIPTDIR: Path): logging_config_file = safe_get(ini, "LOGGING", "log_config_file", default=None) - vars = {'SCRIPTDIR': SCRIPTDIR} # dynamic data for logging configuration + vars: Dict[str, Any] = {'SCRIPTDIR': SCRIPTDIR} # dynamic data for logging configuration if logging_config_file is not None: logging_config_file = Path(logging_config_file) @@ -157,7 +158,7 @@ def activate_for_development(ini: dict, SCRIPTDIR: Path): logger.info("Running in development mode") logger.info(f"Using logging configuration from file: {logging_config_file}") - logger.debug(f"Logging configuration: {devel_config = }") + logger.debug(f"Logging configuration: {devel_config=}") # -------------------------------------------------------------------------------------------- @@ -177,7 +178,7 @@ def configure_logging(config: dict, ini: dict, vars: dict): disable_logging = safe_get(ini, "LOGGING", "disable_logging", default=False) if disable_logging: - logger.warning(f"Logging is disabled by configuration in ini file. {disable_logging = }") + logger.warning(f"Logging is disabled by configuration in ini file. {disable_logging=}") logging.disable() # globally disable all logging of all loggers diff --git a/lib/debug/utils.py b/lib/debug/utils.py index 10d840d9..a758ab3b 100644 --- a/lib/debug/utils.py +++ b/lib/debug/utils.py @@ -14,6 +14,10 @@ import logging logger = logging.getLogger("inkstitch") +# We have some ignores so you don't see errors if you don't have one or more of the profiling libraries installed. +# But in turn those ignores will cause unused-ignore errors if those libraries aren't installed... +# mypy: disable-error-code="unused-ignore" + # safe_get - get value from nested dictionary, return default if key does not exist # - to read nested values from dict - mimic get method of dict with default value @@ -67,7 +71,7 @@ def write_offline_debug_script(debug_script_dir: Path, ini: dict): # environment PATH f.write('# PATH:\n') - f.write(f'# {os.environ.get("PATH","")}\n') + f.write(f'# {os.environ.get("PATH", "")}\n') # for p in os.environ.get("PATH", '').split(os.pathsep): # PATH to list # f.write(f'# {p}\n') @@ -217,6 +221,8 @@ def profile(profiler_type, profile_dir: Path, ini: dict, extension, remaining_ar with_profile(extension, remaining_args, profile_file_path) elif profiler_type == 'pyinstrument': with_pyinstrument(extension, remaining_args, profile_file_path) + elif profiler_type == 'monkeytype': + with_monkeytype(extension, remaining_args, profile_file_path) else: raise ValueError(f"unknown profiler type: '{profiler_type}'") @@ -265,7 +271,7 @@ def with_pyinstrument(extension, remaining_args, profile_file_path: Path): ''' profile with pyinstrument ''' - import pyinstrument + import pyinstrument # type: ignore[import-untyped,import-not-found] profiler = pyinstrument.Profiler() profiler.start() @@ -276,3 +282,25 @@ def with_pyinstrument(extension, remaining_args, profile_file_path: Path): with open(profile_file_path, 'w') as stats_file: stats_file.write(profiler.output_html()) print(f"Profiler: pyinstrument, stats written to '{profile_file_path.name}'. Use browser to see it.", file=sys.stderr) + + +def with_monkeytype(extension, remaining_args, profile_file_path: Path) -> None: + ''' + 'profile' with monkeytype to get type information. This may be handy for anyone who wants to + add type annotations to older parts of our code that don't have them. + + See https://monkeytype.readthedocs.io/en/stable/generation.html for usage instructions. + ''' + import monkeytype # type: ignore[import-not-found] + + # Monkeytype will use these environment variables for the db path and to filter the modules respectively. + # This is easier than using monkeytype's actual config API, anyway. + dbpath = profile_file_path.with_suffix('.sqlite') + os.environ["MT_DB_PATH"] = str(dbpath) + os.environ["MONKEYTYPE_TRACE_MODULES"] = str(Path(__file__).parents[2].name) + + with monkeytype.trace(): + extension.run(args=remaining_args) + + print(f"Profiler: monkeytype, db written to '{dbpath}'.\n\n" + + f"Run 'MT_DB_PATH={dbpath} monkeytype ...' from the inkstitch repo directory.", file=sys.stderr) diff --git a/lib/elements/clone.py b/lib/elements/clone.py index 943428b8..9c3e3e3a 100644 --- a/lib/elements/clone.py +++ b/lib/elements/clone.py @@ -5,11 +5,11 @@ from contextlib import contextmanager from math import degrees -from typing import Dict, Generator, List +from typing import Dict, Generator, List, Optional, Tuple, Any, cast -from inkex import BaseElement, Title, Transform +from inkex import BaseElement, Title, Transform, Vector2d from lxml.etree import _Comment -from shapely import MultiLineString +from shapely import Geometry, MultiLineString, Point as ShapelyPoint from ..commands import (find_commands, is_command_symbol, point_command_symbols_up) @@ -40,12 +40,12 @@ class Clone(EmbroideryElement): name = "Clone" element_name = _("Clone") - def __init__(self, *args, **kwargs): - super(Clone, self).__init__(*args, **kwargs) + def __init__(self, node: BaseElement) -> None: + super(Clone, self).__init__(node) @property @param('clone', _("Clone"), type='toggle', inverse=False, default=True) - def clone(self): + def clone(self) -> bool: return self.get_boolean_param("clone", True) @property @@ -55,7 +55,7 @@ class Clone(EmbroideryElement): unit='deg', type='float') @cache - def clone_fill_angle(self): + def clone_fill_angle(self) -> float: return self.get_float_param('angle') @property @@ -66,15 +66,15 @@ class Clone(EmbroideryElement): type='boolean', default=False) @cache - def flip_angle(self): + def flip_angle(self) -> bool: return self.get_boolean_param('flip_angle', False) - def get_cache_key_data(self, previous_stitch, next_element): + def get_cache_key_data(self, previous_stitch: Any, next_element: EmbroideryElement) -> List[str]: source_node = self.node.href source_elements = self.clone_to_elements(source_node) return [element.get_cache_key(previous_stitch, next_element) for element in source_elements] - def clone_to_elements(self, node) -> List[EmbroideryElement]: + def clone_to_elements(self, node: BaseElement) -> List[EmbroideryElement]: # Only used in get_cache_key_data, actual embroidery uses nodes_to_elements+iterate_nodes from .utils import node_to_elements elements = [] @@ -85,7 +85,7 @@ class Clone(EmbroideryElement): elements.extend(node_to_elements(child, True)) return elements - def to_stitch_groups(self, last_stitch_group=None, next_element=None) -> List[StitchGroup]: + def to_stitch_groups(self, last_stitch_group: Optional[StitchGroup], next_element: Optional[EmbroideryElement] = None) -> List[StitchGroup]: if not self.clone: return [] @@ -96,7 +96,7 @@ class Clone(EmbroideryElement): next_elements = [next_element] if len(elements) > 1: - next_elements = elements[1:] + next_elements + next_elements = cast(List[Optional[EmbroideryElement]], elements[1:]) + next_elements for element, next_element in zip(elements, next_elements): # Using `embroider` here to get trim/stop after commands, etc. element_stitch_groups = element.embroider(last_stitch_group, next_element) @@ -107,26 +107,26 @@ class Clone(EmbroideryElement): return stitch_groups @property - def first_stitch(self): + def first_stitch(self) -> Optional[ShapelyPoint]: first, last = self.first_and_last_element() if first: return first.first_stitch return None - def uses_previous_stitch(self): + def uses_previous_stitch(self) -> bool: first, last = self.first_and_last_element() if first: return first.uses_previous_stitch() - return None + return False - def uses_next_element(self): + def uses_next_element(self) -> bool: first, last = self.first_and_last_element() if last: return last.uses_next_element() - return None + return False @cache - def first_and_last_element(self): + def first_and_last_element(self) -> Tuple[Optional[EmbroideryElement], Optional[EmbroideryElement]]: with self.clone_elements() as elements: if len(elements): return elements[0], elements[-1] @@ -153,7 +153,7 @@ class Clone(EmbroideryElement): for cloned_node in cloned_nodes: cloned_node.delete() - def resolve_clone(self, recursive=True) -> List[BaseElement]: + def resolve_clone(self, recursive: bool = True) -> List[BaseElement]: """ "Resolve" this clone element by copying the node it hrefs as if unlinking the clone in Inkscape. The node will be added as a sibling of this element's node, with its transform and style applied. @@ -162,9 +162,12 @@ class Clone(EmbroideryElement): :param recursive: Recursively "resolve" all child clones in the same manner :returns: A list where the first element is the "resolved" node, and zero or more commands attached to that node """ - parent: BaseElement = self.node.getparent() - source_node: BaseElement = self.node.href - source_parent: BaseElement = source_node.getparent() + parent: Optional[BaseElement] = self.node.getparent() + assert parent is not None, f"Element {self.node.get_id()} should have a parent" + source_node: Optional[BaseElement] = self.node.href + assert source_node is not None, f"Target of {self.node.get_id()} was None!" + source_parent: Optional[BaseElement] = source_node.getparent() + assert source_parent is not None, f"Target {source_node.get_id()} of {self.node.get_id()} should have a parent" cloned_node = clone_with_fixup(parent, source_node) if recursive: @@ -201,7 +204,7 @@ class Clone(EmbroideryElement): if cloned_node.tag == SVG_SYMBOL_TAG: source_transform: Transform = parent.composed_transform() else: - source_transform: Transform = source_parent.composed_transform() + source_transform = source_parent.composed_transform() clone_transform: Transform = self.node.composed_transform() angle_transform = clone_transform @ -source_transform self.apply_angles(cloned_node, angle_transform) @@ -242,7 +245,7 @@ class Clone(EmbroideryElement): # We have to negate the angle because SVG/Inkscape's definition of rotation is clockwise, while Inkstitch uses counter-clockwise fill_vector = (angle_transform @ Transform(f"rotate(${-element_angle})")).apply_to_point((1, 0)) # Same reason for negation here. - element_angle = -degrees(fill_vector.angle) + element_angle = -degrees(fill_vector.angle or 0) # Fallback to 0 if an insane transform is used. else: # If clone_fill_angle is specified, override the angle instead. element_angle = self.clone_fill_angle @@ -252,26 +255,28 @@ class Clone(EmbroideryElement): node.set(INKSTITCH_ATTRIBS['angle'], round(element_angle, 6)) @property - def shape(self): + def shape(self) -> Geometry: path = self.node.get_path() transform = Transform(self.node.composed_transform()) path = path.transform(transform) path = path.to_superpath() return MultiLineString(path[0]) - def center(self, source_node): + def center(self, source_node: BaseElement) -> Vector2d: translate = Transform(f"translate({float(self.node.get('x', '0'))}, {float(self.node.get('y', '0'))})") - transform = get_node_transform(self.node.getparent()) @ translate + parent = self.node.getparent() + assert parent is not None, "This should be part of a tree and therefore have a parent" + transform = get_node_transform(parent) @ translate center = self.node.bounding_box(transform).center return center - def validation_warnings(self): + def validation_warnings(self) -> Generator[CloneWarning, Any, None]: source_node = self.node.href point = self.center(source_node) yield CloneWarning(point) -def is_clone(node): +def is_clone(node: BaseElement) -> bool: if node.tag == SVG_USE_TAG and node.href is not None and not is_command_symbol(node): return True return False @@ -299,7 +304,7 @@ def clone_with_fixup(parent: BaseElement, node: BaseElement) -> BaseElement: ret = clone_children(parent, node) - def fixup_id_attr(node: BaseElement, attr: str): + def fixup_id_attr(node: BaseElement, attr: str) -> None: # Replace the id value for this attrib with the corresponding one in the clone subtree, if applicable. val = node.get(attr) if val is not None: diff --git a/lib/elements/element.py b/lib/elements/element.py index ea2d5d6b..6f2c52e9 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -2,6 +2,7 @@ # # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +from __future__ import annotations import json import sys from contextlib import contextmanager @@ -79,8 +80,9 @@ class EmbroideryElement(object): prop = getattr(cls, attr) if isinstance(prop, property): # The 'param' attribute is set by the 'param' decorator defined above. - if hasattr(prop.fget, 'param'): - params.append(prop.fget.param) + fget = prop.fget + if fget is not None and hasattr(fget, 'param'): + params.append(fget.param) return params @cache @@ -215,18 +217,18 @@ class EmbroideryElement(object): # First, figure out the translation component of the transform. Using a zero # vector completely cancels out the rotation, scale, and skew components. - zero = [0, 0] + zero = (0, 0) zero = inkex.Transform.apply_to_point(node_transform, zero) translate = Point(*zero) # Next, see how the transform affects unit vectors in the X and Y axes. We # need to subtract off the translation or it will affect the magnitude of # the resulting vector, which we don't want. - unit_x = [1, 0] + unit_x = (1, 0) unit_x = inkex.Transform.apply_to_point(node_transform, unit_x) sx = (Point(*unit_x) - translate).length() - unit_y = [0, 1] + unit_y = (0, 1) unit_y = inkex.Transform.apply_to_point(node_transform, unit_y) sy = (Point(*unit_y) - translate).length() @@ -455,7 +457,7 @@ class EmbroideryElement(object): raise NotImplementedError("INTERNAL ERROR: %s must implement shape()", self.__class__) @property - def first_stitch(self): + def first_stitch(self) -> Optional[ShapelyPoint]: # first stitch is an approximation to where the first stitch may possibly be # if not defined through commands or repositioned by the previous element raise NotImplementedError("INTERNAL ERROR: %s must implement first_stitch()", self.__class__) @@ -521,7 +523,7 @@ class EmbroideryElement(object): return lock_start, lock_end - def to_stitch_groups(self, last_stitch_group: Optional[StitchGroup], next_element: Optional[ShapelyPoint] = None) -> List[StitchGroup]: + def to_stitch_groups(self, last_stitch_group: Optional[StitchGroup], next_element: Optional[EmbroideryElement] = None) -> List[StitchGroup]: raise NotImplementedError("%s must implement to_stitch_groups()" % self.__class__.__name__) @debug.time diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py index f8db39d5..ee77cd60 100644 --- a/lib/elements/fill_stitch.py +++ b/lib/elements/fill_stitch.py @@ -808,13 +808,17 @@ class FillStitch(EmbroideryElement): def validation_errors(self): if not self.shape.is_valid: why = explain_validity(self.shape) - message, x, y = re.match(r"(?P<message>.+)\[(?P<x>.+)\s(?P<y>.+)\]", why).groups() + match = re.match(r"(?P<message>.+)\[(?P<x>.+)\s(?P<y>.+)\]", why) + assert match is not None, f"Could not parse validity message '{why}'" + message, x, y = match.groups() yield InvalidShapeError((x, y)) def validation_warnings(self): # noqa: C901 if not self.original_shape.is_valid: why = explain_validity(self.original_shape) - message, x, y = re.match(r"(?P<message>.+)\[(?P<x>.+)\s(?P<y>.+)\]", why).groups() + match = re.match(r"(?P<message>.+)\[(?P<x>.+)\s(?P<y>.+)\]", why) + assert match is not None, f"Could not parse validity message '{why}'" + message, x, y = match.groups() if "Hole lies outside shell" in message: yield UnconnectedWarning((x, y)) else: diff --git a/lib/elements/image.py b/lib/elements/image.py index 695515dc..ad012975 100644 --- a/lib/elements/image.py +++ b/lib/elements/image.py @@ -23,14 +23,16 @@ class ImageObject(EmbroideryElement): name = "Image" def center(self): - transform = get_node_transform(self.node.getparent()) + parent = self.node.getparent() + assert parent is not None, "This should be part of a tree and therefore have a parent" + transform = get_node_transform(parent) center = self.node.bounding_box(transform).center return center def validation_warnings(self): yield ImageTypeWarning(self.center()) - def to_stitch_groups(self, last_stitch_group): + def to_stitch_groups(self, last_stitch_group, next_element): return [] def first_stitch(self): diff --git a/lib/elements/text.py b/lib/elements/text.py index dd886dbc..34ce14e2 100644 --- a/lib/elements/text.py +++ b/lib/elements/text.py @@ -21,7 +21,9 @@ class TextTypeWarning(ObjectTypeWarning): class TextObject(EmbroideryElement): def pointer(self): - transform = get_node_transform(self.node.getparent()) + parent = self.node.getparent() + assert parent is not None, "This should be part of a tree and therefore have a parent" + transform = get_node_transform(parent) point = self.node.bounding_box(transform).center return point diff --git a/lib/elements/utils.py b/lib/elements/utils.py index dfe1eb3a..cf770af4 100644 --- a/lib/elements/utils.py +++ b/lib/elements/utils.py @@ -3,7 +3,7 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from typing import List, Optional +from typing import List, Optional, Iterable from inkex import BaseElement from lxml.etree import Comment @@ -41,7 +41,7 @@ def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: return [MarkerObject(node)] elif node.tag in EMBROIDERABLE_TAGS or is_clone(node): - elements = [] + elements: List[EmbroideryElement] = [] from ..sew_stack import SewStack sew_stack = SewStack(node) @@ -73,7 +73,7 @@ def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: return [] -def nodes_to_elements(nodes): +def nodes_to_elements(nodes: Iterable[BaseElement]) -> List[EmbroideryElement]: elements = [] for node in nodes: elements.extend(node_to_elements(node)) @@ -89,7 +89,8 @@ def iterate_nodes(node: BaseElement, # noqa: C901 def walk(node: BaseElement, selected: bool) -> List[BaseElement]: nodes = [] - if node.tag == Comment: + # lxml-stubs types are wrong, node.tag can be Comment. + if node.tag is Comment: # type:ignore[comparison-overlap] return [] element = EmbroideryElement(node) diff --git a/lib/elements/validation.py b/lib/elements/validation.py index 9ac8e745..8edcc7c5 100644 --- a/lib/elements/validation.py +++ b/lib/elements/validation.py @@ -3,6 +3,7 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +from typing import Optional, List from shapely.geometry import Point as ShapelyPoint from ..utils import Point as InkstitchPoint @@ -21,9 +22,9 @@ class ValidationMessage(object): ''' # Subclasses will fill these in. - name = None - description = None - steps_to_solve = [] + name: Optional[str] = None + description: Optional[str] = None + steps_to_solve: List[str] = [] def __init__(self, position=None, label=""): if isinstance(position, ShapelyPoint): diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index 9b25ff91..289cac31 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -77,76 +77,78 @@ from .update_svg import UpdateSvg from .zigzag_line_to_satin import ZigzagLineToSatin from .zip import Zip -__all__ = extensions = [About, - ApplyPalette, - ApplyThreadlist, - AutoRun, - AutoSatin, - BreakApart, - Cleanup, - CommandsScaleSymbols, - ConvertToSatin, - ConvertToStroke, - CutSatin, - CutworkSegmentation, - DensityMap, - DisplayStackingOrder, - DuplicateParams, - ElementInfo, - FillToSatin, - FillToStroke, - Flip, - GeneratePalette, - GlobalCommands, - GradientBlocks, - Input, - Install, - InstallCustomPalette, - JumpToStroke, - JumpToTrim, - KnockdownFill, - LayerCommands, - Lettering, - LetteringAlongPath, - LetteringCustomFontDir, - LetteringEditJson, - LetteringFontSample, - LetteringForceLockStitches, - LetteringGenerateJson, - LetteringRemoveKerning, - LetteringSetColorSortIndex, - LetteringSvgFontToLayers, - LettersToFont, - ObjectCommands, - ObjectCommandsToggleVisibility, - Outline, - Output, - PaletteSplitText, - PaletteToText, - Params, - PngRealistic, - PngSimple, - Preferences, - Print, - Redwork, - RemoveDuplicatedPoints, - RemoveEmbroiderySettings, - Reorder, - SatinMulticolor, - SelectElements, - SelectionToAnchorLine, - SelectionToGuideLine, - SelectionToPattern, - SewStackEditor, - Simulator, - StitchPlanPreview, - StitchPlanPreviewUndo, - StrokeToLpeSatin, - Tartan, - TestSwatches, - ThreadList, - Troubleshoot, - UnlinkClone, - UpdateSvg, - ZigzagLineToSatin, - Zip] +extensions = [ + About, + ApplyPalette, + ApplyThreadlist, + AutoRun, + AutoSatin, + BreakApart, + Cleanup, + CommandsScaleSymbols, + ConvertToSatin, + ConvertToStroke, + CutSatin, + CutworkSegmentation, + DensityMap, + DisplayStackingOrder, + DuplicateParams, + ElementInfo, + FillToSatin, + FillToStroke, + Flip, + GeneratePalette, + GlobalCommands, + GradientBlocks, + Input, + Install, + InstallCustomPalette, + JumpToStroke, + JumpToTrim, + KnockdownFill, + LayerCommands, + Lettering, + LetteringAlongPath, + LetteringCustomFontDir, + LetteringEditJson, + LetteringFontSample, + LetteringForceLockStitches, + LetteringGenerateJson, + LetteringRemoveKerning, + LetteringSetColorSortIndex, + LetteringSvgFontToLayers, + LettersToFont, + ObjectCommands, + ObjectCommandsToggleVisibility, + Outline, + Output, + PaletteSplitText, + PaletteToText, + Params, + PngRealistic, + PngSimple, + Preferences, + Print, + Redwork, + RemoveDuplicatedPoints, + RemoveEmbroiderySettings, + Reorder, + SatinMulticolor, + SelectElements, + SelectionToAnchorLine, + SelectionToGuideLine, + SelectionToPattern, + SewStackEditor, + Simulator, + StitchPlanPreview, + StitchPlanPreviewUndo, + StrokeToLpeSatin, + Tartan, + TestSwatches, + ThreadList, + Troubleshoot, + UnlinkClone, + UpdateSvg, + ZigzagLineToSatin, + Zip +] diff --git a/lib/extensions/cleanup.py b/lib/extensions/cleanup.py index d5816b5f..366d8547 100644 --- a/lib/extensions/cleanup.py +++ b/lib/extensions/cleanup.py @@ -60,12 +60,12 @@ class Cleanup(InkstitchExtension): def _dry_run(self): errormsg(_("%s elements to remove:" % len(self.elements_to_remove))) for element in self.elements_to_remove: - errormsg(f" - { element.label }: {element.get_id()}") + errormsg(f" - {element.label}: {element.get_id()}") errormsg("\n") errormsg(_("%s groups/layers to remove:" % len(self.groups_to_remove))) for group in self.groups_to_remove: - errormsg(f" - { group.label }: {group.get_id()}") + errormsg(f" - {group.label}: {group.get_id()}") def _remove(self): num_elements_removed = len(self.elements_to_remove) diff --git a/lib/extensions/cutwork_segmentation.py b/lib/extensions/cutwork_segmentation.py index bea14472..c041e359 100644 --- a/lib/extensions/cutwork_segmentation.py +++ b/lib/extensions/cutwork_segmentation.py @@ -136,7 +136,7 @@ class CutworkSegmentation(InkstitchExtension): d = "M " for point in point_list: - d += f"{ point.x }, { point.y } " + d += f"{point.x}, {point.y} " stroke_element = inkex.PathElement(attrib={ "style": color, @@ -188,4 +188,4 @@ class CutworkSegmentation(InkstitchExtension): def path_style(self, element, color): # set stroke color and make it a running stitch - they don't want to cut zigzags - return inkex.Style(element.node.get('style', '')) + inkex.Style(f'stroke-width:1;stroke:{ color };') + return inkex.Style(element.node.get('style', '')) + inkex.Style(f'stroke-width:1;stroke:{color};') diff --git a/lib/extensions/display_stacking_order.py b/lib/extensions/display_stacking_order.py index ba5fda98..f9f83ca4 100644 --- a/lib/extensions/display_stacking_order.py +++ b/lib/extensions/display_stacking_order.py @@ -38,7 +38,7 @@ class DisplayStackingOrder(InkstitchExtension): 'x': str(position[0]), 'y': str(position[1]) }) - text.style = inkex.Style(f"text-anchor: middle;text-align: center;dominant-baseline: middle;font-size: { self.options.font_size }") + text.style = inkex.Style(f"text-anchor: middle;text-align: center;dominant-baseline: middle;font-size: {self.options.font_size}") tspan = inkex.Tspan() tspan.text = str(num) text.add(tspan) diff --git a/lib/extensions/element_info.py b/lib/extensions/element_info.py index b84232b7..06acbb16 100644 --- a/lib/extensions/element_info.py +++ b/lib/extensions/element_info.py @@ -48,7 +48,7 @@ class ElementInfo(InkstitchExtension): ) self.list_items.append(ListItem( - name=f"{ element.node.label } ({ element.node.get_id() })", + name=f"{element.node.label} ({element.node.get_id()})", value=stitch_groups[0].color, headline=True )) @@ -116,7 +116,7 @@ class ElementInfo(InkstitchExtension): stitches_per_group = "" if len(stitch_groups) > 1: - stitches_per_group = f" ({', '.join([str(len(group.stitches)) for group in stitch_groups]) })" + stitches_per_group = f" ({', '.join([str(len(group.stitches)) for group in stitch_groups])})" self.list_items.append(ListItem( name=_("Stitches"), @@ -200,7 +200,7 @@ class ElementInfo(InkstitchExtension): class ListItem: - def __init__(self, name="", value="", headline=False, warning=False): + def __init__(self, name="", value="", headline=False, warning=False) -> None: self.name: str = name self.value: str = value self.headline: bool = headline diff --git a/lib/extensions/fill_to_stroke.py b/lib/extensions/fill_to_stroke.py index 9101eca2..085b3c95 100644 --- a/lib/extensions/fill_to_stroke.py +++ b/lib/extensions/fill_to_stroke.py @@ -67,7 +67,7 @@ class FillToStroke(InkstitchExtension): element_label = element.node.label group_name = element_label or element_id - centerline_group = Group.new(f'{ group_name } { _("center line") }', id=self.uniqueId("centerline_group_")) + centerline_group = Group.new(f'{group_name} { _("center line") }', id=self.uniqueId("centerline_group_")) parent = element.node.getparent() index = parent.index(element.node) + 1 parent.insert(index, centerline_group) @@ -75,7 +75,7 @@ class FillToStroke(InkstitchExtension): transform = Transform(get_correction_transform(parent, child=True)) stroke_width = convert_unit(self.options.line_width_mm, 'px', 'mm') color = element.node.style('fill') - style = f"fill:none;stroke:{ color };stroke-width:{ stroke_width }" + style = f"fill:none;stroke:{color};stroke-width:{stroke_width}" multipolygon = element.shape multipolygon = self._apply_cut_lines(cut_lines, multipolygon) diff --git a/lib/extensions/generate_palette.py b/lib/extensions/generate_palette.py index b87bc179..6cc839fb 100644 --- a/lib/extensions/generate_palette.py +++ b/lib/extensions/generate_palette.py @@ -84,4 +84,4 @@ class GeneratePalette(InkstitchExtension): if __name__ == '__main__': e = GeneratePalette() - e.affect() + e.run() diff --git a/lib/extensions/gradient_blocks.py b/lib/extensions/gradient_blocks.py index 8e341cc8..9ae24736 100644 --- a/lib/extensions/gradient_blocks.py +++ b/lib/extensions/gradient_blocks.py @@ -173,4 +173,4 @@ def gradient_shapes_and_attributes(element, shape, unit_multiplier): if __name__ == '__main__': e = GradientBlocks() - e.effect() + e.run() diff --git a/lib/extensions/palette_to_text.py b/lib/extensions/palette_to_text.py index 729c92fc..8c3db035 100644 --- a/lib/extensions/palette_to_text.py +++ b/lib/extensions/palette_to_text.py @@ -55,4 +55,4 @@ class PaletteToText(InkstitchExtension): if __name__ == '__main__': e = PaletteToText() - e.affect() + e.run() diff --git a/lib/extensions/reorder.py b/lib/extensions/reorder.py index 956c0615..2c10559d 100644 --- a/lib/extensions/reorder.py +++ b/lib/extensions/reorder.py @@ -34,4 +34,4 @@ class Reorder(InkstitchExtension): if __name__ == '__main__': e = Reorder() - e.affect() + e.run() diff --git a/lib/extensions/sew_stack_editor.py b/lib/extensions/sew_stack_editor.py index a7cc9e38..3b703225 100755 --- a/lib/extensions/sew_stack_editor.py +++ b/lib/extensions/sew_stack_editor.py @@ -6,8 +6,8 @@ import sys import wx from wx.lib.agw import ultimatelistctrl as ulc -from wx.lib.checkbox import GenCheckBox -from wx.lib.splitter import MultiSplitterWindow +from wx.lib.checkbox import GenCheckBox # type:ignore[import-untyped] +from wx.lib.splitter import MultiSplitterWindow # type:ignore[import-untyped] from .base import InkstitchExtension from ..debug.debug import debug diff --git a/lib/extensions/stroke_to_lpe_satin.py b/lib/extensions/stroke_to_lpe_satin.py index 7d4ffb3e..3c0ed017 100644 --- a/lib/extensions/stroke_to_lpe_satin.py +++ b/lib/extensions/stroke_to_lpe_satin.py @@ -165,7 +165,7 @@ class StrokeToLpeSatin(InkstitchExtension): class SatinPattern: - def __init__(self, path=None, node_types=None, flip=True, rung_node=1): + def __init__(self, path=None, node_types=None, flip=True, rung_node=1) -> None: self.path: str = path self.node_types: str = node_types self.flip: bool = flip diff --git a/lib/extensions/unlink_clone.py b/lib/extensions/unlink_clone.py index bebfbdb8..b4af7961 100644 --- a/lib/extensions/unlink_clone.py +++ b/lib/extensions/unlink_clone.py @@ -3,7 +3,7 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from typing import List, Tuple +from typing import List, Tuple, cast from inkex import BaseElement, Boolean, Group, errormsg @@ -19,7 +19,7 @@ class UnlinkClone(InkstitchExtension): self.arg_parser.add_argument("--notebook") self.arg_parser.add_argument("-r", "--recursive", dest="recursive", type=Boolean, default=True) - def effect(self): + def effect(self) -> None: recursive: bool = self.options.recursive if not self.get_elements(): @@ -39,15 +39,16 @@ class UnlinkClone(InkstitchExtension): group = Group() for child in resolved[0]: group.append(child) - resolved[0].getparent().replace(resolved[0], group) + parent = cast(BaseElement, resolved[0].getparent()) # Safe assumption that this has a parent. + parent.replace(resolved[0], group) clones_resolved.append((element.node, resolved[0])) - for (clone, resolved) in clones_resolved: + for (clone, resolved_clone) in clones_resolved: clone.delete() - orig_id = resolved.get_id() + orig_id = resolved_clone.get_id() new_id = clone.get_id() # Fix up command backlinks - note this has to happen before we rename so they can actually be found. - for command in EmbroideryElement(resolved).commands: + for command in EmbroideryElement(resolved_clone).commands: backlink_attrib = CONNECTION_START if command.connector.get(CONNECTION_START) == ("#"+orig_id) else CONNECTION_END command.connector.set(backlink_attrib, "#"+new_id) - resolved.set_id(new_id) + resolved_clone.set_id(new_id) diff --git a/lib/gui/edit_json/editable_list.py b/lib/gui/edit_json/editable_list.py index fc033597..8f62f554 100644 --- a/lib/gui/edit_json/editable_list.py +++ b/lib/gui/edit_json/editable_list.py @@ -1,5 +1,5 @@ import wx -from wx.lib.mixins.listctrl import TextEditMixin +from wx.lib.mixins.listctrl import TextEditMixin # type: ignore[import-untyped] class EditableListCtrl(wx.ListCtrl, TextEditMixin): diff --git a/lib/i18n.py b/lib/i18n.py index 2a901a0e..b2a2b002 100644 --- a/lib/i18n.py +++ b/lib/i18n.py @@ -5,30 +5,27 @@ import gettext import os +from typing import Callable, Tuple from .utils import cache, get_resource_dir -_ = translation = None -locale_dir = None - # Use N_ to mark a string for translation but _not_ immediately translate it. # reference: https://docs.python.org/3/library/gettext.html#deferred-translations # Makefile configures pybabel to treat N_() the same as _() -def N_(message): return message +def N_(message: str) -> str: + return message -def _set_locale_dir(): - global locale_dir +def localize(languages=None) -> Tuple[Callable[[str], str], gettext.NullTranslations]: locale_dir = get_resource_dir('locales') - -def localize(languages=None): global translation, _ translation = gettext.translation("inkstitch", locale_dir, fallback=True) _ = translation.gettext + return (_, translation) @cache @@ -53,5 +50,4 @@ def get_languages(): return languages -_set_locale_dir() -localize() +_, translation = localize() diff --git a/lib/lettering/categories.py b/lib/lettering/categories.py index 9a63063a..e7e81b3f 100644 --- a/lib/lettering/categories.py +++ b/lib/lettering/categories.py @@ -7,9 +7,9 @@ from ..i18n import _ class FontCategory: - def __init__(self, cat_id=None, name=None): - self.id: str = cat_id - self.name: str = name + def __init__(self, cat_id: str, name: str) -> None: + self.id = cat_id + self.name = name def __repr__(self): return "FontCategory(%s, %s)" % (self.id, self.name) @@ -35,5 +35,4 @@ FONT_CATEGORIES = [ FontCategory('hebrew', _("Script: hebrew")), FontCategory('japanese', _("Script: japanese")), FontCategory('latin', _("Script: latin")) - ] diff --git a/lib/lettering/font_info.py b/lib/lettering/font_info.py index 74cc0967..8bf35900 100644 --- a/lib/lettering/font_info.py +++ b/lib/lettering/font_info.py @@ -5,7 +5,7 @@ from collections import defaultdict -from fontTools.agl import toUnicode +from fontTools.agl import toUnicode # type:ignore[import-untyped] from inkex import NSS from lxml import etree diff --git a/lib/output.py b/lib/output.py index 4559ca2b..a16b4018 100644 --- a/lib/output.py +++ b/lib/output.py @@ -32,11 +32,6 @@ def get_command(stitch): return pyembroidery.NEEDLE_AT -def _string_to_floats(string): - floats = string.split(',') - return [float(num) for num in floats] - - def get_origin(svg, bounding_box): (minx, miny, maxx, maxy) = bounding_box origin_command = global_command(svg, "origin") diff --git a/lib/sew_stack/stitch_layers/stitch_layer.py b/lib/sew_stack/stitch_layers/stitch_layer.py index 4b34373a..615a36e8 100644 --- a/lib/sew_stack/stitch_layers/stitch_layer.py +++ b/lib/sew_stack/stitch_layers/stitch_layer.py @@ -1,10 +1,12 @@ +from typing import Type from ...utils import coordinate_list_to_point_list from ...utils.dotdict import DotDict +from .stitch_layer_editor import StitchLayerEditor class StitchLayer: # must be overridden in child classes and set to a subclass of StitchLayerEditor - editor_class = None + editor_class: Type[StitchLayerEditor] = None # type:ignore[assignment] # not to be overridden in child classes _defaults = None diff --git a/lib/stitch_plan/lock_stitch.py b/lib/stitch_plan/lock_stitch.py index 899aa382..98219154 100755 --- a/lib/stitch_plan/lock_stitch.py +++ b/lib/stitch_plan/lock_stitch.py @@ -1,5 +1,6 @@ import re from math import degrees +from typing import List, Optional from inkex import DirectedLineSegment, Path from shapely.geometry import LineString @@ -12,16 +13,16 @@ from .stitch import Stitch class LockStitchDefinition: - def __init__(self, lock_id=None, name=None, path=None, preview_image=None): + def __init__(self, lock_id=None, name=None, path=None, preview_image=None) -> None: self.id: str = lock_id self.name: str = name self._path: str = path - self.preview_image: str = None + self.preview_image: Optional[str] = None - def __repr__(self): + def __repr__(self) -> str: return "LockStitchDefinition(%s, %s, %s, %s)" % (self.id, self.name, self._path, self.preview_image) - def stitches(self, stitches, pos, scale): + def stitches(self, stitches, pos, scale) -> List[Stitch]: raise NotImplementedError(f"{self.__class__.__name__} must implement stitches()") diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py index ffa944ae..bce7c05a 100644 --- a/lib/stitch_plan/stitch.py +++ b/lib/stitch_plan/stitch.py @@ -3,6 +3,8 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +from __future__ import annotations # Needed for using the Stitch type as a constructor arg +from typing import Dict, Type, Union, Optional, Set, Any, Iterable, overload from shapely import geometry as shgeo from ..utils.geometry import Point @@ -10,17 +12,80 @@ from ..utils.geometry import Point class Stitch(Point): """A stitch is a Point with extra information telling how to sew it.""" + x: float + y: float + color: Any # Todo: What is this + jump: bool + stop: bool + trim: bool + color_change: bool + min_stitch_length: Optional[float] + tags: Set[str] + + @overload + def __init__( + self, + x: Stitch, + color: Optional[Any] = None, + jump=False, + stop=False, + trim=False, + color_change=False, + min_stitch_length: Optional[float] = None, + tags: Optional[Iterable[str]] = None + ): ... + @overload def __init__( self, - x, y=None, - color=None, + x: Point, + color: Optional[Any] = None, jump=False, stop=False, trim=False, color_change=False, - min_stitch_length=None, - tags=None + min_stitch_length: Optional[float] = None, + tags: Optional[Iterable[str]] = None + ): ... + + @overload + def __init__( + self, + x: shgeo.Point, + color: Optional[Any] = None, + jump=False, + stop=False, + trim=False, + color_change=False, + min_stitch_length: Optional[float] = None, + tags: Optional[Iterable[str]] = None + ): ... + + @overload + def __init__( + self, + x: float, + y: float, + color: Optional[Any] = None, + jump: bool = False, + stop: bool = False, + trim: bool = False, + color_change: bool = False, + min_stitch_length: Optional[float] = None, + tags: Optional[Iterable[str]] = None + ): ... + + def __init__( + self, + x: Union[Stitch, float, Point], + y: Optional[float] = None, + color: Optional[Any] = None, + jump: bool = False, + stop: bool = False, + trim: bool = False, + color_change: bool = False, + min_stitch_length: Optional[float] = None, + tags: Optional[Iterable[str]] = None ): # DANGER: if you add new attributes, you MUST also set their default # values in __new__() below. Otherwise, cached stitch plans can be @@ -32,14 +97,15 @@ class Stitch(Point): # Allow creating a Stitch from another Stitch. Attributes passed as # arguments will override any existing attributes. base_stitch = x - self.x: float = base_stitch.x - self.y: float = base_stitch.y + self.x = base_stitch.x + self.y = base_stitch.y elif isinstance(x, (Point, shgeo.Point)): # Allow creating a Stitch from a Point point = x - self.x: float = point.x - self.y: float = point.y + self.x = point.x + self.y = point.y else: + assert y is not None, "Bad stitch constructor use: No y component?" Point.__init__(self, x, y) self._set('color', color, base_stitch) @@ -54,7 +120,7 @@ class Stitch(Point): if base_stitch is not None: self.add_tags(base_stitch.tags) - def __new__(cls, *args, **kwargs): + def __new__(cls: Type[Stitch], *args, **kwargs) -> Stitch: instance = super().__new__(cls) # Set default values for any new attributes here (see note in __init__() above) @@ -74,7 +140,7 @@ class Stitch(Point): "COLOR CHANGE" if self.color_change else " " ) - def _set(self, attribute, value, base_stitch): + def _set(self, attribute: str, value: Optional[Any], base_stitch: Optional[Stitch]) -> None: # Set an attribute. If the caller passed a Stitch object, use its value, unless # they overrode it with arguments. if base_stitch is not None: @@ -86,11 +152,11 @@ class Stitch(Point): def is_terminator(self) -> bool: return self.trim or self.stop or self.color_change - def add_tags(self, tags): + def add_tags(self, tags: Iterable[str]) -> None: for tag in tags: self.add_tag(tag) - def add_tag(self, tag): + def add_tag(self, tag: str) -> None: """Store arbitrary information about a stitch. Tags can be used to store any information about a stitch. This can be @@ -105,10 +171,10 @@ class Stitch(Point): """ self.tags.add(tag) - def has_tag(self, tag): + def has_tag(self, tag: str) -> bool: return tag in self.tags - def copy(self): + def copy(self) -> Stitch: return Stitch( self.x, self.y, @@ -121,18 +187,18 @@ class Stitch(Point): self.tags ) - def offset(self, offset: Point): + def offset(self, offset: Point) -> Stitch: out = self.copy() out.x += offset.x out.y += offset.y return out - def __json__(self): + def __json__(self) -> Dict[str, Any]: attributes = dict(vars(self)) attributes['tags'] = list(attributes['tags']) return attributes - def __getstate__(self): + def __getstate__(self) -> Dict[str, Any]: # This is used by pickle. We want to sort the tag list so that the # pickled representation is stable, since it's used to generate cache # keys. diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py index 6ad80da0..5d9a6018 100644 --- a/lib/stitches/guided_fill.py +++ b/lib/stitches/guided_fill.py @@ -172,7 +172,7 @@ def apply_stitches(line, max_stitch_length, num_staggers, row_spacing, row_num, if len(points) < 2: coords = line.coords - points = [coords[0], coords[-1]] + points = np.array([coords[0], coords[-1]]) stitched_line = shgeo.LineString(points) diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py index 6144a977..a773fa9a 100644 --- a/lib/stitches/running_stitch.py +++ b/lib/stitches/running_stitch.py @@ -33,7 +33,9 @@ def split_segment_even_n(a, b, segments: int, jitter_sigma: float = 0.0, random_ splits = splits + jitters * (jitter_sigma / segments) # sort the splits in case a bad roll transposes any of them - return [line.interpolate(x, normalized=True) for x in sorted(splits)] + splits.sort() + + return [line.interpolate(x, normalized=True) for x in splits] def split_segment_even_dist(a, b, max_length: float, jitter_sigma: float = 0.0, random_seed=None) -> typing.List[shgeo.Point]: @@ -80,7 +82,7 @@ class AngleInterval(): # partially based on https://fgiesen.wordpress.com/2015/09/24/intervals-in-modular-arithmetic/ def __init__(self, a: float, b: float, all: bool = False): - self.all = all + self.isAll = all self.a = a self.b = b @@ -111,7 +113,7 @@ class AngleInterval(): return AngleInterval(angleB - 1e-6, angleA + 1e-6) def containsAngle(self, angle: float): - if self.all: + if self.isAll: return True return (angle - self.a) % tau <= (self.b - self.a) % tau @@ -122,9 +124,9 @@ class AngleInterval(): # assume that each interval contains less than half the circle (or all of it) if other is None: return None - elif self.all: + elif self.isAll: return other - elif other.all: + elif other.isAll: return self elif self.containsAngle(other.a): if other.containsAngle(self.b): @@ -140,7 +142,7 @@ class AngleInterval(): return None def cutSegment(self, origin: Point, a: Point, b: Point): - if self.all: + if self.isAll: return None segArc = AngleInterval.fromSegment(a - origin, b - origin) if segArc is None: @@ -180,7 +182,8 @@ def cut_segment_with_circle(origin: Point, r: float, a: Point, b: Point) -> Poin return a + d*t -def take_stitch(start: Point, points: typing.Sequence[Point], idx: int, stitch_length: float, tolerance: float): +def take_stitch(start: Point, points: typing.Sequence[Point], idx: int, stitch_length: float, tolerance: float) -> \ + typing.Tuple[typing.Optional[Point], typing.Optional[int]]: # Based on a single step of the Zhao-Saalfeld curve simplification algorithm. # https://cartogis.org/docs/proceedings/archive/auto-carto-13/pdf/linear-time-sleeve-fitting-polyline-simplification-algorithms.pdf # Adds early termination condition based on stitch length. @@ -207,7 +210,7 @@ def take_stitch(start: Point, points: typing.Sequence[Point], idx: int, stitch_l return points[-1], None -def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, tolerance: float): +def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, tolerance: float) -> typing.List[Point]: # Will split a straight line into even-length stitches while still handling curves correctly. # Includes end point but not start point. if len(points) < 2: @@ -216,9 +219,9 @@ def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, to for i in reversed(range(0, len(points) - 1)): distLeft[i] = distLeft[i + 1] + points[i].distance(points[i+1]) - i = 1 + i: typing.Optional[int] = 1 last = points[0] - stitches = [] + stitches: typing.List[Point] = [] while i is not None and i < len(points): d = last.distance(points[i]) + distLeft[i] if d == 0: @@ -233,7 +236,8 @@ def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, to return stitches -def stitch_curve_randomly(points: typing.Sequence[Point], stitch_length: float, tolerance: float, stitch_length_sigma: float, random_seed: str): +def stitch_curve_randomly(points: typing.Sequence[Point], stitch_length: float, tolerance: float, stitch_length_sigma: float, random_seed: str) ->\ + typing.List[Point]: min_stitch_length = max(0, stitch_length * (1 - stitch_length_sigma)) max_stitch_length = stitch_length * (1 + stitch_length_sigma) # Will split a straight line into stitches of random length within the range. @@ -242,7 +246,7 @@ def stitch_curve_randomly(points: typing.Sequence[Point], stitch_length: float, if len(points) < 2: return [] - i = 1 + i: typing.Optional[int] = 1 last = points[0] last_shortened = 0.0 stitches = [] diff --git a/lib/stitches/tartan_fill.py b/lib/stitches/tartan_fill.py index 1ddd8195..c25bb435 100644 --- a/lib/stitches/tartan_fill.py +++ b/lib/stitches/tartan_fill.py @@ -3,6 +3,9 @@ # Copyright (c) 2023 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +# This file needs some more love before it'll pass type checking. +# mypy: ignore-errors=true + from collections import defaultdict from itertools import chain from math import cos, radians, sin diff --git a/lib/svg/path.py b/lib/svg/path.py index 548a82f2..5e6e7007 100644 --- a/lib/svg/path.py +++ b/lib/svg/path.py @@ -9,7 +9,7 @@ from .tags import SVG_GROUP_TAG, SVG_LINK_TAG from .units import get_viewbox_transform -def apply_transforms(path, node): +def apply_transforms(path: inkex.Path, node: inkex.BaseElement) -> inkex.Path: transform = get_node_transform(node) # apply the combined transform to this node's path @@ -18,20 +18,21 @@ def apply_transforms(path, node): return path -def compose_parent_transforms(node, mat): +def compose_parent_transforms(node: inkex.BaseElement, mat: inkex.Transform) -> inkex.Transform: # This is adapted from Inkscape's simpletransform.py's composeParents() # function. That one can't handle nodes that are detached from a DOM. trans = node.get('transform') if trans: mat = inkex.transforms.Transform(trans) @ mat - if node.getparent() is not None: - if node.getparent().tag in [SVG_GROUP_TAG, SVG_LINK_TAG]: - mat = compose_parent_transforms(node.getparent(), mat) + parent = node.getparent() + if parent is not None: + if parent.tag in [SVG_GROUP_TAG, SVG_LINK_TAG]: + mat = compose_parent_transforms(parent, mat) return mat -def get_node_transform(node: inkex.BaseElement): +def get_node_transform(node: inkex.BaseElement) -> inkex.Transform: """ if getattr(node, "composed_transform", None): return node.composed_transform() @@ -52,7 +53,7 @@ def get_node_transform(node: inkex.BaseElement): return transform -def get_correction_transform(node, child=False): +def get_correction_transform(node: inkex.BaseElement, child=False) -> str: """Get a transform to apply to new siblings or children of this SVG node Arguments: @@ -71,7 +72,11 @@ def get_correction_transform(node, child=False): else: # we can ignore the transform on the node itself since it won't apply # to the objects we add - transform = get_node_transform(node.getparent()) + parent = node.getparent() + if parent is not None: + transform = get_node_transform(parent) + else: + transform = inkex.Transform() # now invert it, so that we can position our objects in absolute # coordinates diff --git a/lib/tartan/fill_element.py b/lib/tartan/fill_element.py index 34139e6c..6666181e 100644 --- a/lib/tartan/fill_element.py +++ b/lib/tartan/fill_element.py @@ -12,12 +12,13 @@ def prepare_tartan_fill_element(element: BaseElement) -> None: :param element: svg element with a fill color (path, rectangle, or circle) """ parent_group = element.getparent() - if parent_group.get_id().startswith('inkstitch-tartan'): + if parent_group is not None and parent_group.get_id().startswith('inkstitch-tartan'): # apply tartan group transform to element transform = element.transform @ parent_group.transform element.set('transform', transform) # remove tartan group and place element in parent group outer_group = parent_group.getparent() + assert outer_group is not None, f"Tartan element {element.get_id()} should have a parent group" outer_group.insert(outer_group.index(parent_group), element) outer_group.remove(parent_group) # make sure the element is invisible diff --git a/lib/tartan/palette.py b/lib/tartan/palette.py index d945eb83..25bd2100 100644 --- a/lib/tartan/palette.py +++ b/lib/tartan/palette.py @@ -5,12 +5,14 @@ # Additional credits to: https://github.com/clsn/pyTartan import re -from typing import List +from typing import TYPE_CHECKING, List, cast import wx from inkex import Color from .colors import string_to_color +if TYPE_CHECKING: + from ..gui.tartan.stripe_panel import StripePanel class Palette: @@ -59,7 +61,7 @@ class Palette: stripes = [] for stripe_sizer in outer_sizer.Children: stripe = {'render': 1, 'color': '#000000', 'width': '5'} - stripe_panel = stripe_sizer.GetWindow() + stripe_panel = cast('StripePanel', stripe_sizer.GetWindow()) stripe['render'] = stripe_panel.visibility.Get3StateValue() stripe['color'] = stripe_panel.colorpicker.GetColour().GetAsString(wx.C2S_HTML_SYNTAX) stripe['width'] = stripe_panel.stripe_width.GetValue() diff --git a/lib/tartan/svg.py b/lib/tartan/svg.py index 497d0199..62d737c9 100644 --- a/lib/tartan/svg.py +++ b/lib/tartan/svg.py @@ -7,7 +7,7 @@ import time from collections import defaultdict from copy import copy from itertools import chain -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, cast from inkex import BaseElement, Group, Path, PathElement from networkx import MultiGraph, is_empty @@ -66,12 +66,12 @@ class TartanSvgGroup: :param outline: the outline to be filled with the tartan pattern """ parent_group = outline.getparent() - if parent_group.get_id().startswith('inkstitch-tartan'): + if parent_group is not None and parent_group.get_id().startswith('inkstitch-tartan'): # remove everything but the tartan outline for child in parent_group.iterchildren(): if child != outline: parent_group.remove(child) - group = parent_group + group = cast(Group, parent_group) else: group = Group() group.set('id', f'inkstitch-tartan-{int(time.time())}') @@ -86,6 +86,7 @@ class TartanSvgGroup: # set outline invisible outline.style['display'] = 'none' group.append(outline) + return group def _generate_tartan_group_elements(self, group, outline_shape, transform): dimensions, rotation_center = self._get_dimensions(outline_shape) diff --git a/lib/threads/color.py b/lib/threads/color.py index 58861017..e71ebb06 100644 --- a/lib/threads/color.py +++ b/lib/threads/color.py @@ -11,8 +11,6 @@ from pyembroidery.EmbThread import EmbThread class ThreadColor(object): - hex_str_re = re.compile('#([0-9a-z]{3}|[0-9a-z]{6})', re.I) - def __init__(self, color, name=None, number=None, manufacturer=None, description=None, chart=None): ''' avoid error messages: @@ -36,11 +34,9 @@ class ThreadColor(object): self.rgb = (color.get_red(), color.get_green(), color.get_blue()) return elif isinstance(color, str): - self.rgb = Color.parse_str(color)[1] + self.rgb = Color(color).to('rgb').get_values(False) elif isinstance(color, (list, tuple)): self.rgb = tuple(color) - elif self.hex_str_re.match(color): - self.rgb = Color.parse_str(color)[1] else: raise ValueError("Invalid color: " + repr(color)) diff --git a/lib/utils/cache.py b/lib/utils/cache.py index 18f993e9..cca6296a 100644 --- a/lib/utils/cache.py +++ b/lib/utils/cache.py @@ -8,16 +8,12 @@ import os import pickle import sqlite3 -import diskcache +import diskcache # type: ignore[import-untyped] from lib.utils.settings import global_settings from .paths import get_user_dir - -try: - from functools import lru_cache -except ImportError: - from backports.functools_lru_cache import lru_cache +from functools import lru_cache # simplify use of lru_cache decorator diff --git a/lib/utils/param.py b/lib/utils/param.py index 162dcddc..6def49f7 100644 --- a/lib/utils/param.py +++ b/lib/utils/param.py @@ -1,5 +1,5 @@ class ParamOption: - def __init__(self, param_id=None, name=None, preview_image=None): + def __init__(self, param_id=None, name=None, preview_image=None) -> None: self.id: str = param_id self.name: str = name self.preview_image: str = preview_image diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..e2563424 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,40 @@ +[mypy] +files = lib/**/*.py,tests/**/*.py + +# Some of our code "reuses" variable names in different blocks. Mypy doesn't like that by default. +allow_redefinition = True + +# We use class properties, which mypy considers an error, probably because it's going to be deprecated. +# Ignore that for now. +disable_error_code = misc + +# A handful of strictness increases we can pass as it is +warn_unused_configs = True +warn_redundant_casts = True +warn_unused_ignores = True + +strict_equality = True +extra_checks = True +disallow_subclassing_any = True +disallow_untyped_decorators = True + +# An example of increased strictness: We can increase the strictness of parts of the code as we go. +[mypy-lib.elements.clone] +check_untyped_defs = True +disallow_incomplete_defs = True +disallow_untyped_defs = True + +# This part of the code will need some work before it'll start passing. +[mypy-lib.tartan.*] +ignore_errors = True + +# These libraries are missing type information +[mypy-colormath2.*] +ignore_missing_imports = True + +[mypy-winutils.*] +ignore_missing_imports = True + +# ... And this one is ours but is missing type information for now anyway... +[mypy-pyembroidery.*] +ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index 84bf40d4..57248c4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,15 +3,14 @@ # but the .gitignore file should be updated to ignore the build directory. ./pyembroidery -# get up to date inkex version (Febuary 10, 2024) -inkex @ git+https://gitlab.com/inkscape/extensions.git@618fe5e1e431d4b28a078660bf17afc65335fe39 +# get up to date inkex version (March 1, 2024) +inkex @ git+https://gitlab.com/inkscape/extensions.git@1792934a09046fdef8aab20d05aad9c47f825bf5 # for linux user it may be tricky to install wxPython from sources # prebuilt packages: https://wxpython.org/pages/downloads/index.html # https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ wxPython>=4.1.1 -backports.functools_lru_cache networkx shapely>=2.0.0 lxml @@ -32,9 +31,16 @@ diskcache flask-cors pywinutils ; sys_platform == 'win32' pywin32 ; sys_platform == 'win32' +types-pywin32; sys_platform == 'win32' # Test dependencies. # It should be okay to include these here because this list isn't the one used for bundling dependencies. # Instead Pyinstaller finds what dependencies the project needs based on what inkstitch.py imports pytest +# Misc dev dependencies +types-wxpython +types-appdirs +types-shapely +types-networkx +types-lxml diff --git a/tests/test_clone.py b/tests/test_clone.py index f089cb43..4b4cf101 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -23,22 +23,22 @@ def element_fill_angle(element: EmbroideryElement) -> Optional[float]: class CloneElementTest(TestCase): # Monkey-patch the cahce to forcibly disable it: We may need to refactor this out for tests. - def setUp(self): + def setUp(self) -> None: from pytest import MonkeyPatch self.monkeypatch = MonkeyPatch() self.monkeypatch.setattr(cache_module, "is_cache_disabled", lambda: True) - def tearDown(self): + def tearDown(self) -> None: self.monkeypatch.undo() return super().tearDown() - def assertAngleAlmostEqual(self, a, b): + def assertAngleAlmostEqual(self, a, b) -> None: # Take the mod 180 of the returned angles, because e.g. -130deg and 50deg produce fills along the same angle. # We have to use a precision of 4 decimal digits because of the precision of the matrices as they are stored in the svg trees # generated by these tests. self.assertAlmostEqual(a % 180, b % 180, 4) - def test_not_embroiderable(self): + def test_not_embroiderable(self) -> None: root: SvgDocumentElement = svg() text = root.add(TextElement()) text.text = "Can't embroider this!" @@ -49,7 +49,7 @@ class CloneElementTest(TestCase): stitch_groups = clone.to_stitch_groups(None) self.assertEqual(len(stitch_groups), 0) - def test_not_clone(self): + def test_not_clone(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -67,7 +67,7 @@ class CloneElementTest(TestCase): # These tests make sure the element cloning works as expected, using the `clone_elements` method. - def test_basic(self): + def test_basic(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -80,9 +80,9 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: self.assertEqual(len(elements), element_count()) - self.assertAlmostEqual(element_fill_angle(elements[0]), 30) + self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 30) - def test_hidden_cloned_elements_not_embroidered(self): + def test_hidden_cloned_elements_not_embroidered(self) -> None: root = svg() g = root.add(Group()) g.add(Rectangle(attrib={ @@ -113,7 +113,7 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), element_count()) self.assertEqual(elements[0].node.get(INKSCAPE_LABEL), "NotHidden") - def test_angle_rotated(self): + def test_angle_rotated(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -129,7 +129,7 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), element_count()) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 10) - def test_angle_flipped(self): + def test_angle_flipped(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -145,7 +145,7 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), element_count()) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -30) - def test_angle_flipped_rotated(self): + def test_angle_flipped_rotated(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -162,7 +162,7 @@ class CloneElementTest(TestCase): # Fill angle goes from 30 -> -30 after flip -> -50 after rotate self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -50) - def test_angle_non_uniform_scale(self): + def test_angle_non_uniform_scale(self) -> None: """ The angle isn't *as* well-defined for non-rotational scales, but we try to follow how the slope will be altered. """ @@ -183,7 +183,7 @@ class CloneElementTest(TestCase): # then rotated another -10 degrees to -55 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -55) - def test_angle_inherits_down_tree(self): + def test_angle_inherits_down_tree(self) -> None: """ The stitching angle of a clone is based in part on the relative transforms of the source and clone. """ @@ -207,7 +207,7 @@ class CloneElementTest(TestCase): # Angle goes from 30 -> 40 (g1 -> g2) -> 29 (use) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 29) - def test_angle_not_applied_twice(self): + def test_angle_not_applied_twice(self) -> None: """Make sure that angle changes are not applied twice to an element with both stroke and fill.""" root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ @@ -226,7 +226,7 @@ class CloneElementTest(TestCase): # Angle goes from 0 -> -30 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -30) - def test_angle_not_applied_to_non_fills(self): + def test_angle_not_applied_to_non_fills(self) -> None: """Make sure that angle changes are not applied to non-fill elements.""" root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ @@ -243,7 +243,7 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), element_count()) # One for the stroke, one for the fill, one for the SewStack self.assertIsNone(elements[0].get_param("angle", None)) # Angle as not set, as this isn't a fill - def test_style_inherits(self): + def test_style_inherits(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -262,7 +262,7 @@ class CloneElementTest(TestCase): self.assertEqual(style["stroke"], "skyblue") self.assertEqual(style["stroke-width"], "2") - def test_transform_inherits_from_cloned_element(self): + def test_transform_inherits_from_cloned_element(self) -> None: """ Elements cloned by cloned_elements need to inherit their transform from their href'd element and their use to match what's shown. """ @@ -284,7 +284,7 @@ class CloneElementTest(TestCase): elements[0].node.composed_transform(), Transform().add_translate((5, 10)).add_scale(2, 2)) - def test_transform_inherits_from_tree(self): + def test_transform_inherits_from_tree(self) -> None: root: SvgDocumentElement = svg() g1 = root.add(Group()) g1.set('transform', Transform().add_translate((0, 5)).add_rotate(5)) @@ -308,7 +308,7 @@ class CloneElementTest(TestCase): .add_scale(2, 2), # rect 5) - def test_transform_inherits_from_tree_up_tree(self): + def test_transform_inherits_from_tree_up_tree(self) -> None: root: SvgDocumentElement = svg() g1 = root.add(Group()) g1.set('transform', Transform().add_translate((0, 5)).add_rotate(5)) @@ -343,7 +343,7 @@ class CloneElementTest(TestCase): .add_translate((0, 5)).add_rotate(5), # g1 5) - def test_clone_fill_angle_not_specified(self): + def test_clone_fill_angle_not_specified(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -357,7 +357,7 @@ class CloneElementTest(TestCase): clone = Clone(use) self.assertEqual(clone.clone_fill_angle, None) - def test_clone_fill_angle(self): + def test_clone_fill_angle(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -376,7 +376,7 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), element_count()) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 42) - def test_angle_manually_flipped(self): + def test_angle_manually_flipped(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -396,7 +396,7 @@ class CloneElementTest(TestCase): # Recursive use tests - def test_recursive_uses(self): + def test_recursive_uses(self) -> None: root: SvgDocumentElement = svg() g1 = root.add(Group()) rect = g1.add(Rectangle(attrib={ @@ -433,7 +433,7 @@ class CloneElementTest(TestCase): .add_translate((20, 0)) # u1 ) - def test_recursive_uses_angle(self): + def test_recursive_uses_angle(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -482,7 +482,7 @@ class CloneElementTest(TestCase): # Angle goes from -30 -> -37 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -37) - def test_recursive_uses_angle_with_specified_angle(self): + def test_recursive_uses_angle_with_specified_angle(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -510,7 +510,7 @@ class CloneElementTest(TestCase): # Command clone tests - def test_copies_directly_attached_commands(self): + def test_copies_directly_attached_commands(self) -> None: """ Check that commands attached to the clone target directly are applied to clones. """ @@ -536,7 +536,7 @@ class CloneElementTest(TestCase): self.assertAlmostEqual(cmd_orig.target_point[0]+10, cmd_clone.target_point[0], 4) self.assertAlmostEqual(cmd_orig.target_point[1]+10, cmd_clone.target_point[1], 4) - def test_copies_indirectly_attached_commands(self): + def test_copies_indirectly_attached_commands(self) -> None: """ Check that commands attached to children of the clone target are copied to clones. """ @@ -565,7 +565,7 @@ class CloneElementTest(TestCase): # Checks that trim_after and stop_after commands and settings in cloned elements aren't overridden - def test_trim_after(self): + def test_trim_after(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -582,7 +582,7 @@ class CloneElementTest(TestCase): self.assertGreater(len(stitch_groups), 0) self.assertTrue(stitch_groups[-1].trim_after) - def test_trim_after_command(self): + def test_trim_after_command(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -599,7 +599,7 @@ class CloneElementTest(TestCase): self.assertGreater(len(stitch_groups), 0) self.assertTrue(stitch_groups[-1].trim_after) - def test_trim_after_command_on_clone(self): + def test_trim_after_command_on_clone(self) -> None: """ If the clone element has a trim command, it should apply! """ @@ -619,7 +619,7 @@ class CloneElementTest(TestCase): self.assertGreater(len(stitch_groups), 0) self.assertTrue(stitch_groups[-1].trim_after) - def test_stop_after(self): + def test_stop_after(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -636,7 +636,7 @@ class CloneElementTest(TestCase): self.assertGreater(len(stitch_groups), 0) self.assertTrue(stitch_groups[-1].stop_after) - def test_stop_after_command(self): + def test_stop_after_command(self) -> None: root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -654,7 +654,7 @@ class CloneElementTest(TestCase): self.assertGreater(len(stitch_groups), 0) self.assertTrue(stitch_groups[-1].stop_after) - def test_stop_after_command_on_clone(self): + def test_stop_after_command_on_clone(self) -> None: """ If the clone element has a stop command, it should still apply! """ diff --git a/tests/test_elements_utils.py b/tests/test_elements_utils.py index 4653ac69..2c0a64eb 100644 --- a/tests/test_elements_utils.py +++ b/tests/test_elements_utils.py @@ -10,7 +10,7 @@ from .utils import element_count class ElementsUtilsTest(TestCase): # These tests test two functions at once, but they're sort of complimentary. # Might suggest that they could be combined in a later refactor? - def test_iterate_nodes_to_elements(self): + def test_iterate_nodes_to_elements(self) -> None: root = svg() g = root.add(Group()) rect = g.add(Rectangle(attrib={ @@ -35,7 +35,7 @@ class ElementsUtilsTest(TestCase): self.assertEqual(type(elements[0]), FillStitch) self.assertEqual(elements[0].node, rect) - def test_iterate_nodes_to_elements_root_embroiderable(self): + def test_iterate_nodes_to_elements_root_embroiderable(self) -> None: """ Case where the root node is directly embroiderable """ root = svg() rect = root.add(Rectangle(attrib={ diff --git a/tests/test_lib_svg_svg.py b/tests/test_lib_svg_svg.py index 1d873fb2..825715d6 100644 --- a/tests/test_lib_svg_svg.py +++ b/tests/test_lib_svg_svg.py @@ -6,7 +6,7 @@ from inkex.tester.svg import svg class LibSvgSvgTest(TestCase): - def test_point_upwards(self): + def test_point_upwards(self) -> None: root = svg() rect = root.add(Rectangle(attrib={ "width": "10", @@ -24,7 +24,7 @@ class LibSvgSvgTest(TestCase): 4 ) - def test_point_upwards_mirrored(self): + def test_point_upwards_mirrored(self) -> None: root = svg() rect = root.add(PathElement(attrib={ "d": "M 0,0 L 10,0 0,5 Z", |
