Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.2.4
current_version = 0.3.0
commit = False
tag = False
allow_dirty = False
Expand Down
102 changes: 52 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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
Expand All @@ -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:_
Expand All @@ -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

Expand Down Expand Up @@ -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)
```
231 changes: 228 additions & 3 deletions gcodeparser/__init__.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 0 additions & 8 deletions gcodeparser/commands.py

This file was deleted.

Loading