From c309ffae859ce3690e786642583cadcb74c6cc15 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Thu, 11 Dec 2025 15:45:49 +0300 Subject: [PATCH 1/2] Make it iterative (memory efficient); Improve typing and styling --- README.md | 102 +++++++-------- gcodeparser/__init__.py | 231 +++++++++++++++++++++++++++++++++- gcodeparser/commands.py | 8 -- gcodeparser/gcode_parser.py | 133 -------------------- main.py | 2 +- setup.py | 10 +- test/test_element_type.py | 20 ++- test/test_gcode_line.py | 64 +++++----- test/test_get_lines.py | 233 +++++++++++++++++++++++------------ test/test_parse_from_file.py | 175 ++++++++++++++++++++++++++ test/test_split_params.py | 47 +++---- 11 files changed, 677 insertions(+), 348 deletions(-) delete mode 100644 gcodeparser/commands.py delete mode 100644 gcodeparser/gcode_parser.py create mode 100644 test/test_parse_from_file.py diff --git a/README.md b/README.md index ba37779..5b47cb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GcodeParser -A simple gcode parser that takes a string of text and returns a list where each gcode command is seperated into a python object. +A simple gcode parser that can be used to parse a gcode file into python `GcodeLine` objects. The structure of the python object is: @@ -11,10 +11,12 @@ GcodeLine( command = ('G', 1), params = {'X': 10, 'Y': -2.5}, comment = 'this is a comment', + line_index = 0, + type = Commands.MOVE # Commands.MOVE, Commands.COMMENT, Commands.OTHER, Commands.TOOLCHANGE ) ``` -# Install +## Install ``` pip install gcodeparser @@ -26,50 +28,24 @@ Alternatively: pip install -e "git+https://github.com/AndyEveritt/GcodeParser.git@master#egg=gcodeparser" ``` -# Usage +## Usage ```python -from gcodeparser import GcodeParser +from gcodeparser import parse_gcode_lines -# open gcode file and store contents as variable -with open('my_gcode.gcode', 'r') as f: - gcode = f.read() - -GcodeParser(gcode).lines # get parsed gcode lines -``` - -## Include Comments - -`GcodeParser` takes a second argument called `include_comments` which defaults to `False`. If this is set to `True` then any line from the gcode file which only contains a comment will also be included in the output. - -```py -gcode = ( - 'G1 X1 ; this comment is always included\n', - '; this comment will only be included if `include_comments=True`', -) - -GcodeParser(gcode, include_comments=True).lines -``` - -If `include_comments` is `True` then the comment line will be in the form of: +# Recommended: iterate over lines in a file +with open('my_gcode.gcode', 'r') as f: # note that file should be open during iteration + for line in parse_gcode_lines(f, include_comments=False): + print(line) -```python -GcodeLine( - command = (';', None), - params = {}, - comment = 'this comment will only be included if `include_comments=True`', -) -``` - -## Converting a File - -```python -from gcodeparser import GcodeParser +# Alternative: open gcode file and parse lines into a list without iteration +with open('my_gcode.gcode', 'r') as f: + lines = list(parse_gcode_lines(f, include_comments=False)) -with open('3DBenchy.gcode', 'r') as f: +# Also, we can convert string to parsed lines +with open('my_gcode.gcode', 'r') as f: gcode = f.read() -parsed_gcode = GcodeParser(gcode) -parsed_gcode.lines +lines = list(parse_gcode_lines(gcode, include_comments=False)) ``` _output:_ @@ -96,21 +72,38 @@ _output:_ ] ``` -## Convert Command Tuple to String +### Include Comments -The `GcodeLine`class has a property `command_str` which will return the command tuple as a string. ie `('G', 91)` -> `"G91"`. +`parse_gcode_lines()` takes a second argument called `include_comments` which defaults to `False`. If this is set to `True` then any line from the gcode file which only contains a comment will also be included in the output. -## Changing back to Gcode String +```python +from gcodeparser import parse_gcode_lines -The `GcodeLine` class has a property `gcode_str` which will return the equivalent gcode string. +gcode = """G1 X1 ; this comment is always included +; this comment will only be included if `include_comments=True`""" -> This was called `to_gcode()` in version 0.0.6 and before. +lines = list(parse_gcode_lines(gcode, include_comments=True)) +``` -## Parameters +If `include_comments` is `True` then the comment line will be in the form of: -The `GcodeLine` class has a several helper methods to get and manipulate gcode parameters. +```python +GcodeLine( + command = (';', None), + params = {}, + comment = 'this comment will only be included if `include_comments=True`', +) +``` -For an example `GcodeLine` `line`: +### Convert Command Tuple to String + +The `GcodeLine` class has a property `command_str` which will return the command tuple as a string. ie `('G', 91)` -> `"G91"`. + +### Changing back to Gcode String + +The `GcodeLine` class has a property `gcode_str` which will return the equivalent gcode string. + +> This was called `to_gcode()` in version 0.0.6 and before. ### Retrieving Params @@ -140,6 +133,15 @@ To delete a param, use the method `delete_param(param: str)` line.delete_param('X') ``` -## Converting to DataFrames +### Converting to DataFrames -If for whatever reason you want to convert your list of `GcodeLine` objects into a pandas dataframe, simply use `pd.DataFrame(GcodeParser(gcode).lines)` +If for whatever reason you want to convert your list of `GcodeLine` objects into a pandas dataframe: + +```python +from gcodeparser import parse_gcode_lines +import pandas as pd + +with open('my_gcode.gcode', 'r') as f: + lines = list(parse_gcode_lines(f)) +df = pd.DataFrame(lines) +``` diff --git a/gcodeparser/__init__.py b/gcodeparser/__init__.py index 2d4dcab..4851519 100644 --- a/gcodeparser/__init__.py +++ b/gcodeparser/__init__.py @@ -1,4 +1,229 @@ -__version__ = "0.2.4" +__version__ = "0.3.0" +__all__ = ["parse_gcode_lines", "GcodeParser", "GcodeLine", "Commands", "infer_element_type", "parse_parameters"] -from .gcode_parser import GcodeParser, GcodeLine -from .commands import Commands +import io +import re +import warnings +from collections.abc import Iterator +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Type + + +class GcodeParser: + """ + .. deprecated:: 0.3.0 + GcodeParser is deprecated and not recommended for use. + Use :func:`parse_gcode_lines` instead, which provides a more efficient iterator-based approach. + + Parse gcode into a list of GcodeLine objects. + + .. warning:: + This class is deprecated. Use :func:`parse_gcode_lines` instead. + """ + + gcode: str + include_comments: bool + lines: list["GcodeLine"] + + def __init__(self, gcode: str, include_comments=False): + warnings.warn( + "GcodeParser is deprecated and not recommended. Use parse_gcode_lines() instead.", + DeprecationWarning, + stacklevel=2, + ) + self.gcode = gcode + self.include_comments = include_comments + self.lines = list(parse_gcode_lines(self.gcode, self.include_comments)) + + +class Commands(Enum): + COMMENT = 0 + MOVE = 1 + OTHER = 2 + TOOLCHANGE = 3 + + +@dataclass +class GcodeLine: + command: tuple[str, int] | tuple[str, None] + params: dict[str, float | str] + comment: str + line_index: int + type: Commands = field(init=False) + + def __post_init__(self): + if self.command[0] == "G" and self.command[1] in (0, 1, 2, 3): + self.type = Commands.MOVE + elif self.command[0] == ";": + self.type = Commands.COMMENT + elif self.command[0] == "T": + self.type = Commands.TOOLCHANGE + else: + self.type = Commands.OTHER + + @property + def command_str(self) -> str: + return f"{self.command[0]}{self.command[1] if self.command[1] is not None else ''}" + + def get_param( + self, + param: str, + return_type: Type[Any] | None = None, + default: float | str | bool | None = None, + ) -> float | str | bool | None: + """ + Returns the value of the param if it exists, otherwise it will the default value. + If `return_type` is set, the return value will be type cast. + """ + try: + if return_type is None: + return self.params[param] + else: + return return_type(self.params[param]) + except KeyError: + return default + + def update_param(self, param: str, value: int | float) -> float | str | bool | None: + if self.get_param(param) is None: + return None + if type(value) not in (int, float): + raise TypeError(f"Type {type(value)} is not a valid parameter type") + self.params[param] = value + return self.get_param(param) + + def delete_param(self, param: str) -> None: + if self.get_param(param) is None: + return + self.params.pop(param) + + @property + def gcode_str(self) -> str: + command = self.command_str + + def param_value(param: str) -> str: + value = self.get_param(param) + is_flag_parameter = value is True + if is_flag_parameter: + return "" + return str(value) + + params = " ".join(f"{param}{param_value(param)}" for param in self.params.keys()) + comment = f"; {self.comment}" if self.comment != "" else "" + if command == ";": + return comment + return f"{command} {params} {comment}".strip() + + +GCODE_LINE_PATTERN = re.compile( + r'(?!; *.+)(G|M|T|g|m|t)(\d+)(([ \t]*(?!G|M|g|m)\w(".*"|([-+\d\.]*)))*)[ \t]*(;[ \t]*(.*))?|;[ \t]*(.+)' +) +PARAMS_PATTERN = re.compile(r'((?!\d)\w+?)\s*(".*"|(\d+\.?)+|[-+]?\d*\.?\d*)') +DOUBLE_DOT_PATTERN = re.compile(r"\..*\.") +FLOAT_PATTERN = re.compile(r"[+-]?\d*\.\d+") + + +def parse_gcode_lines(gcode: io.TextIOBase | io.StringIO | str, include_comments: bool = False) -> Iterator[GcodeLine]: + """ + Parse gcode from a file-like object, StringIO object, or string and yield GcodeLine objects one at a time. + + Args: + gcode: The gcode content as a file-like object, StringIO object, or string + include_comments: Whether to include comment-only lines + + Yields: + GcodeLine objects representing parsed gcode commands + """ + if isinstance(gcode, str): + gcode = io.StringIO(gcode) + + for line_index, gcode_line in enumerate(gcode): + # Find all matches on this line + matches = list(GCODE_LINE_PATTERN.finditer(gcode_line)) + if not matches: + continue + + # Separate command matches from comment-only matches + command_matches = [] + comment_match = None + + for match in matches: + groups = match.groups() + if groups[0]: # Has a command (G/M/T) + command_matches.append(match) + elif include_comments: # Comment-only line + comment_match = match + + # Handle comment-only lines + if comment_match and not command_matches: + groups = comment_match.groups() + yield GcodeLine( + command=(";", None), + params={}, + comment=(groups[-1] or "").strip(), + line_index=line_index, + ) + continue + + # Process all commands on this line + for i, match in enumerate(command_matches): + groups = match.groups() + command: tuple[str, int] | tuple[str, None] = (groups[0].upper(), int(groups[1])) + params = parse_parameters(groups[2] or "") + + # Comments are attached to the last command on the line + comment = "" + if i == len(command_matches) - 1: + # Get comment from the match's groups (group index -2 is the inline comment) + comment = (groups[-2] or "").strip() + # If no inline comment in this match, check if there's a comment match after + if not comment and comment_match: + comment_pos = comment_match.start() + if comment_pos > match.end(): + comment = (comment_match.groups()[-1] or "").strip() + + yield GcodeLine( + command=command, + params=params, + comment=comment, + line_index=line_index, + ) + + +def infer_element_type(element: str) -> type[int] | type[float] | type[str]: + """ + Infer the Python type of a gcode parameter element. + + Args: + element: The parameter value string + + Returns: + The inferred type (int, float, or str) + """ + if '"' in element or DOUBLE_DOT_PATTERN.search(element): + return str + if FLOAT_PATTERN.search(element): + return float + return int + + +def parse_parameters(line: str) -> dict[str, float | str | bool]: + """ + Parse parameter string from a gcode line into a dictionary. + + Args: + line: The parameter portion of a gcode line + + Returns: + Dictionary mapping parameter names to their values + """ + elements = PARAMS_PATTERN.findall(line) + params: dict[str, float | str | bool] = {} + for element in elements: + if element[1] == "": + params[element[0].upper()] = True + continue + element_type = infer_element_type(element[1]) + params[element[0].upper()] = element_type(element[1]) + + return params diff --git a/gcodeparser/commands.py b/gcodeparser/commands.py deleted file mode 100644 index 2c32492..0000000 --- a/gcodeparser/commands.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - - -class Commands(Enum): - COMMENT = 0 - MOVE = 1 - OTHER = 2 - TOOLCHANGE = 3 diff --git a/gcodeparser/gcode_parser.py b/gcodeparser/gcode_parser.py deleted file mode 100644 index a35e2b5..0000000 --- a/gcodeparser/gcode_parser.py +++ /dev/null @@ -1,133 +0,0 @@ -from typing import List, Dict, Tuple, Union -from dataclasses import dataclass -import re -from .commands import Commands - - -@dataclass -class GcodeLine: - command: Union[Tuple[str, int], Tuple[str, None]] - params: Dict[str, Union[float, str]] - comment: str - - def __post_init__(self): - if self.command[0] == 'G' and self.command[1] in (0, 1, 2, 3): - self.type = Commands.MOVE - elif self.command[0] == ';': - self.type = Commands.COMMENT - elif self.command[0] == 'T': - self.type = Commands.TOOLCHANGE - else: - self.type = Commands.OTHER - - @property - def command_str(self): - return f"{self.command[0]}{self.command[1] if self.command[1] is not None else ''}" - - def get_param(self, param: str, return_type=None, default=None): - """ - Returns the value of the param if it exists, otherwise it will the default value. - If `return_type` is set, the return value will be type cast. - """ - try: - if return_type is None: - return self.params[param] - else: - return return_type(self.params[param]) - except KeyError: - return default - - def update_param(self, param: str, value: Union[int, float]): - if self.get_param(param) is None: - return - if type(value) not in (int, float): - raise TypeError(f"Type {type(value)} is not a valid parameter type") - self.params[param] = value - return self.get_param(param) - - def delete_param(self, param: str): - if self.get_param(param) is None: - return - self.params.pop(param) - - @property - def gcode_str(self): - command = self.command_str - - def param_value(param): - value = self.get_param(param) - is_flag_parameter = value is True - if is_flag_parameter: - return "" - return value - - params = " ".join(f"{param}{param_value(param)}" for param in self.params.keys()) - comment = f"; {self.comment}" if self.comment != '' else "" - if command == ';': - return comment - return f"{command} {params} {comment}".strip() - - -class GcodeParser: - def __init__(self, gcode: str, include_comments=False): - self.gcode = gcode - self.lines: List[GcodeLine] = get_lines(self.gcode, include_comments) - self.include_comments = include_comments - - -def get_lines(gcode, include_comments=False): - regex = r'(?!; *.+)(G|M|T|g|m|t)(\d+)(([ \t]*(?!G|M|g|m)\w(".*"|([-+\d\.]*)))*)[ \t]*(;[ \t]*(.*))?|;[ \t]*(.+)' - regex_lines = re.findall(regex, gcode) - lines = [] - for line in regex_lines: - if line[0]: - command = (line[0].upper(), int(line[1])) - comment = line[-2] - params = split_params(line[2]) - - elif include_comments: - command = (';', None) - comment = line[-1] - params = {} - - else: - continue - - lines.append( - GcodeLine( - command=command, - params=params, - comment=comment.strip(), - )) - - return lines - - -def element_type(element: str): - if re.search(r'"', element): - return str - if re.search(r'\..*\.', element): - return str - if re.search(r'[+-]?\d*\.\d+', element): - return float - return int - - -def split_params(line): - regex = r'((?!\d)\w+?)(".*"|(\d+\.?)+|[-+]?\d*\.?\d*)' - elements = re.findall(regex, line) - params = {} - for element in elements: - if element[1] == '': - params[element[0].upper()] = True - continue - params[element[0].upper()] = element_type(element[1])(element[1]) - - return params - - -if __name__ == '__main__': - with open('3DBenchy.gcode', 'r') as f: - gcode = f.read() - parsed_gcode = GcodeParser(gcode) - pass diff --git a/main.py b/main.py index 9d46b76..598ed4c 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ import argparse import os -from gcodeparser import GcodeParser +from gcodeparser import GcodeParser if __name__ == '__main__': parser = argparse.ArgumentParser( diff --git a/setup.py b/setup.py index 40614d7..c9ed983 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ -from setuptools import find_packages, setup import pathlib +from setuptools import find_packages, setup + # The directory containing this file HERE = pathlib.Path(__file__).parent @@ -9,13 +10,10 @@ setup( name="gcodeparser", - version="0.2.4", + version="0.3.0", include_package_data=True, packages=find_packages(), - - install_requires=[ - ], - + install_requires=[], author="Andy Everitt", author_email="andreweveritt@e3d-online.com", description="Python gcode parser", diff --git a/test/test_element_type.py b/test/test_element_type.py index 1c4c682..fe6ad2e 100644 --- a/test/test_element_type.py +++ b/test/test_element_type.py @@ -1,35 +1,33 @@ -from gcodeparser.gcode_parser import ( - element_type, -) +from gcodeparser import infer_element_type def test_element_type_int(): - assert element_type('109321') == int + assert infer_element_type("109321") is int def test_element_type_neg_int(): - assert element_type('-109321') == int + assert infer_element_type("-109321") is int def test_element_type_float(): - assert element_type('109321.0') == float + assert infer_element_type("109321.0") is float def test_element_type_float2(): - assert element_type('109321.012345') == float + assert infer_element_type("109321.012345") is float def test_element_type_neg_float(): - assert element_type('-1.0') == float + assert infer_element_type("-1.0") is float def test_element_type_neg_float2(): - assert element_type('-1.013456') == float + assert infer_element_type("-1.013456") is float def test_element_type_str(): - assert element_type('192.168.0.1') == str + assert infer_element_type("192.168.0.1") is str def test_element_type_str2(): - assert element_type('"test string"') == str + assert infer_element_type('"test string"') is str diff --git a/test/test_gcode_line.py b/test/test_gcode_line.py index 6ae48fc..1914f29 100644 --- a/test/test_gcode_line.py +++ b/test/test_gcode_line.py @@ -1,71 +1,71 @@ -from gcodeparser.gcode_parser import ( - GcodeLine, - GcodeParser, - get_lines, - element_type, - split_params, -) -from gcodeparser.commands import Commands +from gcodeparser import Commands, GcodeLine def test_post_init_move(): line = GcodeLine( - command=('G', 1), - params={'X': 10, 'Y': 20}, - comment='this is a comment', + command=("G", 1), + params={"X": 10, "Y": 20}, + comment="this is a comment", + line_index=0, ) assert line.type == Commands.MOVE def test_post_init_toolchange(): line = GcodeLine( - command=('T', 1), + command=("T", 1), params={}, - comment='this is a comment', + comment="this is a comment", + line_index=0, ) assert line.type == Commands.TOOLCHANGE def test_post_init_other(): line = GcodeLine( - command=('G', 91), - params={'X': 10, 'Y': 20}, - comment='this is a comment', + command=("G", 91), + params={"X": 10, "Y": 20}, + comment="this is a comment", + line_index=0, ) assert line.type == Commands.OTHER def test_command_str(): line = GcodeLine( - command=('G', 91), - params={'X': 10, 'Y': 20}, - comment='this is a comment', + command=("G", 91), + params={"X": 10, "Y": 20}, + comment="this is a comment", + line_index=0, ) - assert line.command_str == 'G91' + assert line.command_str == "G91" def test_to_gcode(): line = GcodeLine( - command=('G', 91), - params={'X': 10, 'Y': 20}, - comment='this is a comment', + command=("G", 91), + params={"X": 10, "Y": 20}, + comment="this is a comment", + line_index=0, ) - assert line.gcode_str == 'G91 X10 Y20 ; this is a comment' + assert line.gcode_str == "G91 X10 Y20 ; this is a comment" def test_flag_parameter_to_gcode(): line = GcodeLine( - command=('G', 28), - params={'X': True}, - comment='', + command=("G", 28), + params={"X": True}, + comment="", + line_index=0, ) - assert line.gcode_str == 'G28 X' + assert line.gcode_str == "G28 X" def test_flag_parameter_2_to_gcode(): line = GcodeLine( - command=('G', 28), - params={'X': True, 'Y': 1}, - comment='', + command=("G", 28), + params={"X": True, "Y": 1}, + comment="", + line_index=0, ) - assert line.gcode_str == 'G28 X Y1' + assert line.gcode_str == "G28 X Y1" diff --git a/test/test_get_lines.py b/test/test_get_lines.py index 2ded4d2..fe8293b 100644 --- a/test/test_get_lines.py +++ b/test/test_get_lines.py @@ -1,153 +1,222 @@ -from gcodeparser.gcode_parser import ( - GcodeLine, - GcodeParser, - get_lines, - element_type, - split_params, -) -from gcodeparser.commands import Commands +from gcodeparser import GcodeLine, parse_gcode_lines def test_no_params(): line = GcodeLine( - command=('G', 21), + command=("G", 21), params={}, - comment='', + comment="", + line_index=0, ) - assert get_lines('G21')[0] == line + assert next(parse_gcode_lines("G21")) == line def test_params(): line = GcodeLine( - command=('G', 1), - params={'X': 10, 'Y': 20}, - comment='', + command=("G", 1), + params={"X": 10, "Y": 20}, + comment="", + line_index=0, ) - assert get_lines('G1 X10 Y20')[0] == line + assert next(parse_gcode_lines("G1 X10 Y20")) == line def test_params_with_explicit_positive_values(): line = GcodeLine( - command=('G', 1), - params={'X': 10, 'Y': 20}, - comment='', + command=("G", 1), + params={"X": 10, "Y": 20}, + comment="", + line_index=0, ) - assert get_lines('G1 X+10 Y+20')[0] == line + assert next(parse_gcode_lines("G1 X+10 Y+20")) == line def test_2_commands_line(): line1 = GcodeLine( - command=('G', 91), + command=("G", 91), params={}, - comment='', + comment="", + line_index=0, ) line2 = GcodeLine( - command=('G', 1), - params={'X': 10, 'Y': 20}, - comment='', + command=("G", 1), + params={"X": 10, "Y": 20}, + comment="", + line_index=0, ) - lines = get_lines('G91 G1 X10 Y20') + lines = list(parse_gcode_lines("G91 G1 X10 Y20")) + assert len(lines) == 2 assert lines[0] == line1 assert lines[1] == line2 def test_string_params(): line = GcodeLine( - command=('M', 550), - params={'P': '"hostname"'}, - comment='', + command=("M", 550), + params={"P": '"hostname"'}, + comment="", + line_index=0, ) - assert get_lines('M550 P"hostname"')[0] == line + assert next(parse_gcode_lines('M550 P"hostname"')) == line def test_ip_address_params(): line = GcodeLine( - command=('M', 552), - params={'P': '192.168.0.1', 'S': 1}, - comment='', + command=("M", 552), + params={"P": "192.168.0.1", "S": 1}, + comment="", + line_index=0, ) - assert get_lines('M552 P192.168.0.1 S1')[0] == line + assert next(parse_gcode_lines("M552 P192.168.0.1 S1")) == line def test_inline_comment(): line = GcodeLine( - command=('G', 1), - params={'X': 10, 'Y': 20}, - comment='this is a comment', + command=("G", 1), + params={"X": 10, "Y": 20}, + comment="this is a comment", + line_index=0, ) - assert get_lines('G1 X10 Y20 ; this is a comment')[0] == line - assert get_lines('G1 X10 Y20 ; this is a comment')[0] == line - assert get_lines('G1 X10 Y20 \t; \t this is a comment')[0] == line - assert get_lines('G1 X10 Y20 \t; \t this is a comment')[0] == line + assert next(parse_gcode_lines("G1 X10 Y20 ; this is a comment")) == line + assert next(parse_gcode_lines("G1 X10 Y20 ; this is a comment")) == line + assert next(parse_gcode_lines("G1 X10 Y20 \t; \t this is a comment")) == line + assert next(parse_gcode_lines("G1 X10 Y20 \t; \t this is a comment")) == line def test_inline_comment2(): line = GcodeLine( - command=('G', 1), - params={'X': 10, 'Y': 20}, - comment='this is a comment ; with a dummy comment for bants', + command=("G", 1), + params={"X": 10, "Y": 20}, + comment="this is a comment ; with a dummy comment for bants", + line_index=0, ) - assert get_lines('G1 X10 Y20 ; this is a comment ; with a dummy comment for bants')[0] == line + assert next(parse_gcode_lines("G1 X10 Y20 ; this is a comment ; with a dummy comment for bants")) == line def test_include_comment_true(): line = GcodeLine( - command=(';', None), + command=(";", None), params={}, - comment='this is a comment', + comment="this is a comment", + line_index=0, ) - assert get_lines('; this is a comment', include_comments=True)[0] == line + assert next(parse_gcode_lines("; this is a comment", include_comments=True)) == line def test_include_comment_false(): - assert len(get_lines('; this is a comment', include_comments=False)) == 0 + assert next(parse_gcode_lines("; this is a comment", include_comments=False), None) is None def test_multi_line(): lines = [ GcodeLine( - command=('G', 91), + command=("G", 91), params={}, - comment='', - ), GcodeLine( - command=('G', 1), - params={'X': -10, 'Y': 20}, - comment='inline comment', - ), GcodeLine( - command=('G', 1), - params={'Z': 0.5}, - comment='', - ), GcodeLine( - command=('T', 1), + comment="", + line_index=0, + ), + GcodeLine( + command=("G", 1), + params={"X": -10, "Y": 20}, + comment="inline comment", + line_index=1, + ), + GcodeLine( + command=("G", 1), + params={"Z": 0.5}, + comment="", + line_index=2, + ), + GcodeLine( + command=("T", 1), params={}, - comment='', - ), GcodeLine( - command=('M', 350), - params={'T': 100}, - comment='', - )] - assert get_lines('G91\nG1 X-10 Y20 ; inline comment\nG1 Z0.5\nT1\nM350 T100') == lines - assert get_lines('G91G1 X-10 Y20;inline comment\nG1 Z0.5\nT1M350 T100') == lines - assert get_lines(' \tG91\n\tG1\t X-10 Y20 \t ;\t inline comment\nG1 Z0.5\nT1\nM350 T100') == lines - assert get_lines('G91\nG1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100', - include_comments=False) == lines - assert get_lines('G91 G1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100', - include_comments=False) == lines + comment="", + line_index=3, + ), + GcodeLine( + command=("M", 350), + params={"T": 100}, + comment="", + line_index=4, + ), + ] + + def compare_without_line_index(lines1: list[GcodeLine], lines2: list[GcodeLine]) -> bool: + if len(lines1) != len(lines2): + return False + for line1, line2 in zip(lines1, lines2): + if line1.command != line2.command: + return False + if line1.params != line2.params: + return False + if line1.comment != line2.comment: + return False + return True + + assert compare_without_line_index( + list(parse_gcode_lines("G91\nG1 X-10 Y20 ; inline comment\nG1 Z0.5\nT1\nM350 T100")), lines + ) + assert compare_without_line_index( + list(parse_gcode_lines("G91G1 X-10 Y20;inline comment\nG1 Z0.5\nT1M350 T100")), lines + ) + assert compare_without_line_index( + list(parse_gcode_lines(" \tG91\n\tG1\t X-10 Y20 \t ;\t inline comment\nG1 Z0.5\nT1\nM350 T100")), lines + ) + assert compare_without_line_index( + list( + parse_gcode_lines( + "G91\nG1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100", + include_comments=False, + ) + ), + lines, + ) + assert compare_without_line_index( + list( + parse_gcode_lines( + "G91 G1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100", + include_comments=False, + ) + ), + lines, + ) + assert compare_without_line_index( + list(parse_gcode_lines(" \tG91\n\tG1\t X-10 Y20 \t ;\t inline comment\nG1 Z0.5\nT1\nM350 T100")), lines + ) + assert compare_without_line_index( + list( + parse_gcode_lines( + "G91\nG1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100", + include_comments=False, + ) + ), + lines, + ) + assert compare_without_line_index( + list( + parse_gcode_lines( + "G91 G1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100", + include_comments=False, + ) + ), + lines, + ) def test_multi_line2(): - """ We want to ignore things that look like gcode in the comments - """ + """We want to ignore things that look like gcode in the comments""" lines = [ GcodeLine( - command=('G', 91), + command=("G", 91), params={}, - comment='', + comment="", + line_index=0, ), GcodeLine( - command=('G', 1), - params={'X': 100}, - comment='comment G90' - ) + command=("G", 1), + params={"X": 100}, + comment="comment G90", + line_index=0, + ), ] - assert get_lines('G91 G1 X100 ; comment G90') == lines + assert list(parse_gcode_lines("G91 G1 X100 ; comment G90")) == lines diff --git a/test/test_parse_from_file.py b/test/test_parse_from_file.py new file mode 100644 index 0000000..485cab5 --- /dev/null +++ b/test/test_parse_from_file.py @@ -0,0 +1,175 @@ +import io +import tempfile +from pathlib import Path + +from gcodeparser import parse_gcode_lines + + +def test_parse_from_stringio(): + """Test parsing from StringIO object""" + gcode_content = "G21\nG1 X10 Y20\nM104 S200" + stringio = io.StringIO(gcode_content) + + lines = list(parse_gcode_lines(stringio)) + + assert len(lines) == 3 + assert lines[0].command == ("G", 21) + assert lines[1].command == ("G", 1) + assert lines[1].params == {"X": 10, "Y": 20} + assert lines[2].command == ("M", 104) + assert lines[2].params == {"S": 200} + + +def test_parse_from_file_object(): + """Test parsing from a file-like object (TextIO)""" + gcode_content = "G21\nG1 X10 Y20 ; move to position\nM104 S200" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines = list(parse_gcode_lines(f)) + + assert len(lines) == 3 + assert lines[0].command == ("G", 21) + assert lines[1].command == ("G", 1) + assert lines[1].params == {"X": 10, "Y": 20} + assert lines[1].comment == "move to position" + assert lines[2].command == ("M", 104) + assert lines[2].params == {"S": 200} + finally: + temp_path.unlink() + + +def test_parse_from_file_with_comments(): + """Test parsing from file with include_comments=True""" + gcode_content = "G21\n; this is a comment\nG1 X10 Y20" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines = list(parse_gcode_lines(f, include_comments=True)) + + assert len(lines) == 3 + assert lines[0].command == ("G", 21) + assert lines[1].command == (";", None) + assert lines[1].comment == "this is a comment" + assert lines[2].command == ("G", 1) + finally: + temp_path.unlink() + + +def test_parse_from_file_without_comments(): + """Test parsing from file with include_comments=False""" + gcode_content = "G21\n; this is a comment\nG1 X10 Y20" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines = list(parse_gcode_lines(f, include_comments=False)) + + assert len(lines) == 2 + assert lines[0].command == ("G", 21) + assert lines[1].command == ("G", 1) + finally: + temp_path.unlink() + + +def test_parse_from_file_iteration(): + """Test that we can iterate over file lines one at a time""" + gcode_content = "G21\nG1 X10 Y20\nM104 S200" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines = [] + for line in parse_gcode_lines(f, include_comments=False): + lines.append(line) + + assert len(lines) == 3 + assert lines[0].command == ("G", 21) + assert lines[1].command == ("G", 1) + assert lines[2].command == ("M", 104) + finally: + temp_path.unlink() + + +def test_parse_from_string_vs_file(): + """Test that parsing from string and file gives same results""" + gcode_content = "G21\nG1 X10 Y20 ; comment\nM104 S200" + + # Parse from string + lines_from_string = list(parse_gcode_lines(gcode_content)) + + # Parse from file + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines_from_file = list(parse_gcode_lines(f)) + + assert len(lines_from_string) == len(lines_from_file) + for str_line, file_line in zip(lines_from_string, lines_from_file): + assert str_line.command == file_line.command + assert str_line.params == file_line.params + assert str_line.comment == file_line.comment + finally: + temp_path.unlink() + + +def test_parse_from_file_multiline_commands(): + """Test parsing multiple commands on same line from file""" + gcode_content = "G91 G1 X10 Y20\nM104 S200 M106 S50" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines = list(parse_gcode_lines(f)) + + assert len(lines) == 4 + assert lines[0].command == ("G", 91) + assert lines[1].command == ("G", 1) + assert lines[1].params == {"X": 10, "Y": 20} + assert lines[2].command == ("M", 104) + assert lines[2].params == {"S": 200} + assert lines[3].command == ("M", 106) + assert lines[3].params == {"S": 50} + finally: + temp_path.unlink() + + +def test_parse_from_file_empty_lines(): + """Test parsing file with empty lines""" + gcode_content = "G21\n\nG1 X10\n\nM104 S200\n" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gcode') as f: + f.write(gcode_content) + temp_path = Path(f.name) + + try: + with open(temp_path, 'r') as f: + lines = list(parse_gcode_lines(f)) + + assert len(lines) == 3 + assert lines[0].command == ("G", 21) + assert lines[1].command == ("G", 1) + assert lines[2].command == ("M", 104) + finally: + temp_path.unlink() + diff --git a/test/test_split_params.py b/test/test_split_params.py index 5b79fa7..174d228 100644 --- a/test/test_split_params.py +++ b/test/test_split_params.py @@ -1,59 +1,62 @@ -from gcodeparser.gcode_parser import ( - GcodeLine, - GcodeParser, - get_lines, - element_type, - split_params, -) -from gcodeparser.commands import Commands +from gcodeparser import parse_parameters def test_split_int_params(): - assert split_params(' P0 S1 X10') == {'P': 0, 'S': 1, 'X': 10} + assert parse_parameters(" P0 S1 X10") == {"P": 0, "S": 1, "X": 10} def test_split_float_params(): - assert split_params(' P0.1 S1.1345 X10.0') == {'P': 0.1, 'S': 1.1345, 'X': 10.0} + assert parse_parameters(" P0.1 S1.1345 X10.0") == {"P": 0.1, "S": 1.1345, "X": 10.0} def test_split_sub1_params(): - assert split_params(' P0.00001 S-0.00021 X.0001 Y-.003213') == {'P': 0.00001, 'S': -0.00021, 'X': 0.0001, 'Y': -0.003213} + assert parse_parameters(" P0.00001 S-0.00021 X.0001 Y-.003213") == { + "P": 0.00001, + "S": -0.00021, + "X": 0.0001, + "Y": -0.003213, + } def test_split_string_params(): - assert split_params(' P"string"') == {'P': '"string"'} + assert parse_parameters(' P"string"') == {"P": '"string"'} def test_split_string_with_semicolon_params(): - assert split_params(' P"string ; semicolon"') == {'P': '"string ; semicolon"'} + assert parse_parameters(' P"string ; semicolon"') == {"P": '"string ; semicolon"'} def test_split_neg_int_params(): - assert split_params(' P-0 S-1 X-10') == {'P': 0, 'S': -1, 'X': -10} + assert parse_parameters(" P-0 S-1 X-10") == {"P": 0, "S": -1, "X": -10} + def test_split_positive_int_params(): - assert split_params(' P+0 S+1 X+10') == {'P': 0, 'S': 1, 'X': 10} + assert parse_parameters(" P+0 S+1 X+10") == {"P": 0, "S": 1, "X": 10} def test_split_neg_float_params(): - assert split_params(' P-0.1 S-1.1345 X-10.0') == {'P': -0.1, 'S': -1.1345, 'X': -10.0} + assert parse_parameters(" P-0.1 S-1.1345 X-10.0") == {"P": -0.1, "S": -1.1345, "X": -10.0} + def test_split_positive_float_params(): - assert split_params(' P+0.1 S+1.1345 X+10.0') == {'P': 0.1, 'S': 1.1345, 'X': 10.0} + assert parse_parameters(" P+0.1 S+1.1345 X+10.0") == {"P": 0.1, "S": 1.1345, "X": 10.0} def test_split_ip_params(): - assert split_params('P192.168.0.1 S1') == {'P': '192.168.0.1', 'S': 1} + assert parse_parameters("P192.168.0.1 S1") == {"P": "192.168.0.1", "S": 1} def test_split_no_space_params(): - assert split_params('P0.1S1.1345X10.0A"string"') == {'P': 0.1, 'S': 1.1345, 'X': 10.0, 'A': '"string"'} + assert parse_parameters('P0.1S1.1345X10.0A"string"') == {"P": 0.1, "S": 1.1345, "X": 10.0, "A": '"string"'} + def test_split_no_value_params(): - assert split_params(' X') == {'X': True} + assert parse_parameters(" X") == {"X": True} + def test_split_multi_no_value_params(): - assert split_params(' XYZ') == {'X': True, 'Y': True, 'Z': True} + assert parse_parameters(" XYZ") == {"X": True, "Y": True, "Z": True} + def test_split_multi_no_value_spaced_params(): - assert split_params(' X Y Z') == {'X': True, 'Y': True, 'Z': True} + assert parse_parameters(" X Y Z") == {"X": True, "Y": True, "Z": True} From 197f7bc4e81e3f72a17ab2ef968eca2baeb5f11d Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Thu, 11 Dec 2025 15:47:07 +0300 Subject: [PATCH 2/2] Release 0.3.0 --- .bumpversion.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a9f6674..77cc2e5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.2.4 +current_version = 0.3.0 commit = False tag = False allow_dirty = False