From e2835c9f47a647670f51da9002cc75434e37abc0 Mon Sep 17 00:00:00 2001 From: Robert Roos Date: Fri, 27 Feb 2026 14:11:23 +0100 Subject: [PATCH] Replaced all typing stuff by primitives (#49) --- src/tctools/common.py | 31 +++++++-------- src/tctools/format/format_class.py | 27 +++++++------ src/tctools/format/format_rules.py | 39 ++++++++++--------- src/tctools/git_info/git_info_class.py | 3 +- .../make_release/make_release_class.py | 21 +++++----- src/tctools/patch_plc/patch_plc_class.py | 13 ++++--- src/tctools/xml_sort/xml_sort_class.py | 5 +-- tests/conftest.py | 7 ++-- tests/test_patch_plc.py | 3 +- 9 files changed, 72 insertions(+), 77 deletions(-) diff --git a/src/tctools/common.py b/src/tctools/common.py index 2cfaba4..d83f783 100644 --- a/src/tctools/common.py +++ b/src/tctools/common.py @@ -2,8 +2,9 @@ import sys from abc import ABC, abstractmethod from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from collections.abc import Generator from pathlib import Path -from typing import Any, Dict, Generator, List, Optional +from typing import Any from lxml import etree @@ -20,7 +21,7 @@ # The result of a files argument - like: `provided_path: [files]` -FileGroups = Dict[Path, List[Path]] +FileGroups = dict[Path, list[Path]] # Path.glob() only allows symlink recursion from Python 3.13: @@ -33,14 +34,14 @@ class Tool(ABC): ``argparse`` is done in the constructor, CLI arguments should be passed there. """ - LOGGER_NAME: Optional[str] = None + LOGGER_NAME: str | None = None # Default value for file filter argument: - FILTER_DEFAULT: List[str] + FILTER_DEFAULT: list[str] - CONFIG_KEY: Optional[str] = None + CONFIG_KEY: str | None = None - PATH_VARIABLES: List[str] = [] # Names of options that are considered file paths + PATH_VARIABLES: list[str] = [] # Names of options that are considered file paths def __init__(self, *args): """Pass e.g. ``sys.args[1:]`` (skipping the script part of the arguments). @@ -54,7 +55,7 @@ def __init__(self, *args): action.dest for action in parser._actions if action.dest != "help" # noqa } - self.config_file: Optional[Path] = None + self.config_file: Path | None = None config = self.make_config() if self.CONFIG_KEY: @@ -114,7 +115,7 @@ def set_arguments(cls, parser): default="INFO", ) - def make_config(self) -> Dict[str, Any]: + def make_config(self) -> dict[str, Any]: """Get configuration from possible files.""" config = {} self.config_file = self._find_files_upwards( @@ -131,9 +132,7 @@ def make_config(self) -> Dict[str, Any]: return config @classmethod - def _find_files_upwards( - cls, directory: Path, filenames: List[str] - ) -> Optional[Path]: + def _find_files_upwards(cls, directory: Path, filenames: list[str]) -> Path | None: """Find a file with a given name in the directory or it's parents. First hit on any of the filenames is returned. @@ -194,7 +193,7 @@ def __init__(self, *args): # Preserve `CDATA` XML flags: self.xml_parser = etree.XMLParser(strip_cdata=False) - self.header_before: Optional[str] = None # Header of the last XML path + self.header_before: str | None = None # Header of the last XML path self.files_checked = 0 # Files read by parser self.files_to_alter = 0 # Files that seem to require changes @@ -242,7 +241,7 @@ def set_main_argument(cls, parser): ) @staticmethod - def get_xml_header(file: str) -> Optional[str]: + def get_xml_header(file: str) -> str | None: """Get raw XML header as string.""" with open(file, "r") as fh: # Search only the start of the path, otherwise give up @@ -264,8 +263,8 @@ def get_xml_tree(self, path: str | Path) -> ElementTree: @classmethod def find_files( cls, - targets: str | List[str], - filters: None | List[str] = None, + targets: str | list[str], + filters: None | list[str] = None, recursive: bool = True, skip_check: bool = False, ) -> FileGroups: @@ -292,7 +291,7 @@ def find_files( if isinstance(targets, (str, Path)): targets = [targets] - def add_file(g: List[Path], f: Path): + def add_file(g: list[Path], f: Path): """Little local method to prevent duplicate paths.""" if f not in files_unique: files_unique.add(f) diff --git a/src/tctools/format/format_class.py b/src/tctools/format/format_class.py index f878085..3d111e3 100644 --- a/src/tctools/format/format_class.py +++ b/src/tctools/format/format_class.py @@ -1,6 +1,5 @@ import re from collections import OrderedDict -from typing import List, Optional, Tuple, Type from editorconfig import get_properties @@ -16,8 +15,8 @@ FormatVariablesAlign, ) -RowCol = Tuple[int, int] -Segment = Tuple[Kind, List[str], str] +RowCol = tuple[int, int] +Segment = tuple[Kind, list[str], str] class XmlMachine: @@ -31,9 +30,9 @@ def __init__(self): self._row = 0 # Line number inside path self._col = 0 # Position inside line - self.regions: List[Tuple[RowCol, Kind, str]] = [] + self.regions: list[tuple[RowCol, Kind, str]] = [] - def parse(self, content: List[str]): + def parse(self, content: list[str]): """Progress machine line by line.""" self._kind = Kind.XML self._row = 0 @@ -97,7 +96,7 @@ class Formatter(TcTool): CONFIG_KEY = "format" - _RULE_CLASSES: List[Type[FormattingRule]] = [] + _RULE_CLASSES: list[type[FormattingRule]] = [] def __init__(self, *args): super().__init__(*args) @@ -106,7 +105,7 @@ def __init__(self, *args): # them between methods self._file = "" self._properties = OrderedDict() - self._rules: List[FormattingRule] = [] + self._rules: list[FormattingRule] = [] self._number_corrections = 0 # Track number of changes for the current file @@ -119,7 +118,7 @@ def set_arguments(cls, parser): return parser @classmethod - def register_rule(cls, new_rule: Type[FormattingRule]): + def register_rule(cls, new_rule: type[FormattingRule]): """Incorporate a new formatting rule (accounting for its priority).""" cls._RULE_CLASSES.append(new_rule) sorted(cls._RULE_CLASSES, key=lambda item: item.PRIORITY) @@ -170,7 +169,7 @@ def format_file(self, path: str): if rule.WHOLE_FILE: self.apply_rule(rule, content) - segments: List[Segment] = list(self.split_code_segments(content)) + segments: list[Segment] = list(self.split_code_segments(content)) for kind, segment, _ in segments: # Changes are done in-place @@ -188,7 +187,7 @@ def format_file(self, path: str): self.files_resaved += 1 @staticmethod - def split_code_segments(content: List[str]): + def split_code_segments(content: list[str]): """Copy content, split into XML and code sections. Function is a generator, each pair is yielded. @@ -197,7 +196,7 @@ def split_code_segments(content: List[str]): directly, without extra newlines. :param: File content as list - :return: List[Segment] + :return: list[Segment] """ if not content: return # Nothing to yield @@ -230,11 +229,11 @@ def split_code_segments(content: List[str]): yield kind_prev, lines, name_prev - def format_segment(self, content: List[str], kind: Kind): + def format_segment(self, content: list[str], kind: Kind): """Format a specific segment of code. :param content: Text to reformat (changed in place!) - :param kind: Type of the content + :param kind: type of the content """ if kind == Kind.XML: return # Do nothing @@ -243,7 +242,7 @@ def format_segment(self, content: List[str], kind: Kind): if not rule.WHOLE_FILE: # Skip otherwise self.apply_rule(rule, content, kind) - def apply_rule(self, rule, content, kind: Optional[Kind] = None): + def apply_rule(self, rule, content, kind: Kind | None = None): """Run a rule over some content and handle results.""" rule.format(content, kind) corrections = rule.consume_corrections() diff --git a/src/tctools/format/format_rules.py b/src/tctools/format/format_rules.py index 17fda89..a09e7b3 100644 --- a/src/tctools/format/format_rules.py +++ b/src/tctools/format/format_rules.py @@ -1,11 +1,12 @@ import math import re from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, OrderedDict, Tuple, Type +from collections import OrderedDict +from typing import Any from .format_extras import Kind -Correction = Tuple[int, str] +Correction = tuple[int, str] class FormattingRule(ABC): @@ -23,7 +24,7 @@ class FormattingRule(ABC): def __init__(self, properties: OrderedDict): self._properties = properties - self._corrections: List[Correction] = [] + self._corrections: list[Correction] = [] # Universal properties: @@ -34,13 +35,13 @@ def __init__(self, properties: OrderedDict): "tab_width", default=self._indent_size, value_type=int ) - self._indent_style: Optional[str] = self._properties.get("indent_style", None) + self._indent_style: str | None = self._properties.get("indent_style", None) self._indent_str: str = " " * self._indent_size if self._indent_style and self._indent_style == "tab": self._indent_str = "\t" - self._end_of_line: Optional[str] = self._properties.get("end_of_line", None) + self._end_of_line: str | None = self._properties.get("end_of_line", None) options = {"lf": "\n", "cr": "\r", "crlf": "\r\n"} self._line_ending: str = options.get(self._end_of_line, "\n") @@ -58,7 +59,7 @@ def get_property( self, name: str, default: Any = None, - value_type: Optional[Type] = None, + value_type: type | None = None, ) -> Any: """Get item from ``_properties``, parsing as needed. @@ -81,7 +82,7 @@ def get_property( return value_type(value) @abstractmethod - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): """Fun rule to format text. :param content: Text to format (changed in place!) @@ -96,7 +97,7 @@ def add_correction(self, message: str, line_nr: int): """ self._corrections.append((line_nr, message)) - def consume_corrections(self) -> List[Correction]: + def consume_corrections(self) -> list[Correction]: """Return listed corrections and reset list.""" corrections = self._corrections self._corrections = [] @@ -112,7 +113,7 @@ class FormatTabs(FormattingRule): def __init__(self, *args): super().__init__(*args) - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): if self._indent_style == "tab": re_search = self._re_spaces elif self._indent_style == "space": @@ -165,7 +166,7 @@ def __init__(self, *args): "trim_trailing_whitespace", False, value_type=bool ) - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): if not self._remove_tr_ws: return # Nothing to do for i, line in enumerate(content): @@ -185,7 +186,7 @@ def __init__(self, *args): "insert_final_newline", False, value_type=bool ) - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): if not self._insert_final_newline: return @@ -232,7 +233,7 @@ def __init__(self, *args): else: raise ValueError(f"Unrecognized file ending `{self._line_ending}`") - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): if self._end_of_line is None: return # Nothing specified @@ -279,7 +280,7 @@ def __init__(self, *args): self._re_newlines = re.compile(r"[\r\n]+$") - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): if not self._align: return # Disabled by config @@ -288,14 +289,14 @@ def format(self, content: List[str], kind: Optional[Kind] = None): self.format_argument_list(content) - def format_argument_list(self, content: List[str]): + def format_argument_list(self, content: list[str]): """Format entire declaration section""" # Get variable definitions, split up and keyed by content index: - variable_definitions: Dict[int, List[Optional[str]]] = {} + variable_definitions: dict[int, list[str]] | None = {} # Biggest size of each chunk across all lines: - max_chunk_sizes: List[Optional[int]] = [None] * 3 + max_chunk_sizes: list[int | None] = [None] * 3 for i, line in enumerate(content): match = self._re_variable.match(line) @@ -414,7 +415,7 @@ def __init__(self, *args): re.VERBOSE | re.MULTILINE, ) - def format(self, content: List[str], kind: Optional[Kind] = None): + def format(self, content: list[str], kind: Kind | None = None): if self._parentheses is None: return # Nothing to do @@ -465,12 +466,12 @@ def format(self, content: List[str], kind: Optional[Kind] = None): @staticmethod def find_and_match_braces( text: str, brace_left: str = "(", brace_right: str = ")" - ) -> Tuple[int, int]: + ) -> tuple[int, int]: """Step through braces in a string. Note that levels can step into negative. - :return: Tuple of (strpos, level), where strpos is the zero-index position of + :return: tuple of (strpos, level), where strpos is the zero-index position of the brace itself and level is the nested level it indicates """ level = 0 diff --git a/src/tctools/git_info/git_info_class.py b/src/tctools/git_info/git_info_class.py index 2e70ff3..2b82d9a 100644 --- a/src/tctools/git_info/git_info_class.py +++ b/src/tctools/git_info/git_info_class.py @@ -2,7 +2,6 @@ from datetime import datetime from logging import Logger from pathlib import Path -from typing import Optional, Set from git import GitCommandError, Repo @@ -22,7 +21,7 @@ class GitSetter: _DATETIME_FORMAT = "%d-%m-%Y %H:%M:%S" def __init__( - self, repo: Repo, logger: Logger, tolerate_dirty: Optional[Set[Path]] = None + self, repo: Repo, logger: Logger, tolerate_dirty: set[Path] | None = None ): self._repo: Repo = repo self._logger = logger diff --git a/src/tctools/make_release/make_release_class.py b/src/tctools/make_release/make_release_class.py index 81c37d5..fe19382 100644 --- a/src/tctools/make_release/make_release_class.py +++ b/src/tctools/make_release/make_release_class.py @@ -2,7 +2,6 @@ import shutil from pathlib import Path from tempfile import TemporaryDirectory -from typing import List, Optional from git import Repo from lxml import etree @@ -24,10 +23,10 @@ def __init__(self, *args): super().__init__(*args) # Bunch of attributes to easily share data between methods: - self.version: Optional[str] = None - self.destination_dir: Optional[Path] = None - self.archive_source: Optional[Path] = None - self.config_dir: Optional[Path] = None + self.version: str | None = None + self.destination_dir: Path | None = None + self.archive_source: Path | None = None + self.config_dir: Path | None = None @classmethod def set_arguments(cls, parser): @@ -143,7 +142,7 @@ def run(self) -> int: plc_project = self.glob_first(boot_dir, "*.tpzip") name = plc_project.stem.lower().replace(" ", "_") - hmi_bin_dir: Optional[Path] = None + hmi_bin_dir: Path | None = None if self.args.include_hmi: html_file = self.glob_first(source_dir, "bin/*.html") hmi_bin_dir = html_file.parent @@ -211,7 +210,7 @@ def add_additional_files(self): return - def validate_release(self, temp_dir: Path) -> List[str]: + def validate_release(self, temp_dir: Path) -> list[str]: """ :param temp_dir: Root of temporary directory @@ -235,7 +234,7 @@ def validate_release(self, temp_dir: Path) -> List[str]: return errors - def check_cpu(self, root: ElementTree) -> List[str]: + def check_cpu(self, root: ElementTree) -> list[str]: """Validate CPU configuration.""" if self.args.check_cpu is None: return [] @@ -255,14 +254,14 @@ def check_cpu(self, root: ElementTree) -> List[str]: return [] - def check_devices(self, root: ElementTree) -> List[str]: + def check_devices(self, root: ElementTree) -> list[str]: """Validate device configuration.""" errors = [] if self.args.check_devices is None: return errors - devices: List[Element] = root.xpath("//TcSmProject/Project/Io/Device") + devices: list[Element] = root.xpath("//TcSmProject/Project/Io/Device") for i, device in enumerate(devices): if "File" in device.attrib: # Replace by file reference @@ -286,7 +285,7 @@ def check_devices(self, root: ElementTree) -> List[str]: return errors - def check_version_variable(self, temp_dir: Path) -> List[str]: + def check_version_variable(self, temp_dir: Path) -> list[str]: """Validate the version variable matches the release version. :param temp_dir: diff --git a/src/tctools/patch_plc/patch_plc_class.py b/src/tctools/patch_plc/patch_plc_class.py index 1862c83..e438e72 100644 --- a/src/tctools/patch_plc/patch_plc_class.py +++ b/src/tctools/patch_plc/patch_plc_class.py @@ -1,8 +1,9 @@ from argparse import RawDescriptionHelpFormatter +from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum from pathlib import Path, PurePath, PureWindowsPath -from typing import Any, Dict, Iterable, List, Set +from typing import Any from lxml import etree @@ -13,8 +14,8 @@ class FileItems: """A set of files and folders, typically grouped under one source input.""" - folders: Set[Path] = field(default_factory=set) - files: Set[Path] = field(default_factory=set) + folders: set[Path] = field(default_factory=set) + files: set[Path] = field(default_factory=set) def add(self, other: "FileItems"): self.folders |= other.folders @@ -45,7 +46,7 @@ def intersection(a: "FileItems", b: "FileItems") -> "FileItems": ) -FileItemsGroups = Dict[Path, FileItems] +FileItemsGroups = dict[Path, FileItems] class PatchPlc(TcTool): @@ -54,7 +55,7 @@ class PatchPlc(TcTool): LOGGER_NAME = "patch_plc" # TwinCAT PLC source files: - FILTER_DEFAULT: List[str] = [ + FILTER_DEFAULT: list[str] = [ "*.TcPOU", "*.TcGVL", "*.TcDUT", @@ -351,7 +352,7 @@ def sources_to_remove( Important: only the keys of `new_sources` are considered! I.e., the files to remove need not exist on the filesystem. - Listens to `self.args.recursive`. + listens to `self.args.recursive`. """ to_remove = FileItems() diff --git a/src/tctools/xml_sort/xml_sort_class.py b/src/tctools/xml_sort/xml_sort_class.py index a1a391d..1343d6f 100644 --- a/src/tctools/xml_sort/xml_sort_class.py +++ b/src/tctools/xml_sort/xml_sort_class.py @@ -1,5 +1,4 @@ import re -from typing import Dict, List from lxml import etree @@ -14,7 +13,7 @@ class XmlSorter(TcTool): LOGGER_NAME = "xml_sorter" - FILTER_DEFAULT: List[str] = ["*.tsproj", "*.xti", "*.plcproj"] + FILTER_DEFAULT: list[str] = ["*.tsproj", "*.xti", "*.plcproj"] CONFIG_KEY = "xml_sort" @@ -172,7 +171,7 @@ def get_tag(node: Element) -> str: return tag @staticmethod - def get_attrib(node: Element) -> Dict[str, str]: + def get_attrib(node: Element) -> dict[str, str]: """Yield node attributes, with namespace stripped.""" attributes = {} for key, value in node.attrib.items(): diff --git a/tests/conftest.py b/tests/conftest.py index e86dcb8..c96592e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ import shutil from pathlib import Path -from typing import List import pytest @@ -22,7 +21,7 @@ def plc_code(tmp_path): def assert_order_of_lines_in_file( - expected: List[str], file: str, is_substring=False, check_true=True + expected: list[str], file: str, is_substring=False, check_true=True ): """Assert the expected lines occur in the given order in the path. @@ -31,7 +30,7 @@ def assert_order_of_lines_in_file( :param expected: :param file: :param is_substring: When True, use ``... in ...`` instead of equal - :param check_true: Set to False to assert the opposite + :param check_true: set to False to assert the opposite """ idx = 0 @@ -64,7 +63,7 @@ def check_line(expected_line: str, line_to_check: str) -> bool: ) == check_true, "Did not encounter right number of expected lines" -def assert_strings_have_substrings(expected: List[List[str]], actual: List[str]): +def assert_strings_have_substrings(expected: list[list[str]], actual: list[str]): """Assert substrings occur in exactly one but any set of lines.""" actual = [line for line in actual if len(line) > 0] # Remove emtpy strings diff --git a/tests/test_patch_plc.py b/tests/test_patch_plc.py index 20749d9..c31977c 100644 --- a/tests/test_patch_plc.py +++ b/tests/test_patch_plc.py @@ -2,7 +2,6 @@ import subprocess import sys from pathlib import Path, PureWindowsPath -from typing import List import pytest @@ -16,7 +15,7 @@ def path_to_str(p: Path) -> str: return str(PureWindowsPath(p)) -def to_paths(*paths: str) -> List[Path]: +def to_paths(*paths: str) -> list[Path]: """Turn a set of strings into Path objects.""" return [Path(p) for p in paths]