├── .bumpversion.cfg ├── .github └── workflows │ ├── python-publish.yml │ └── python-test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── gcodeparser ├── __init__.py ├── commands.py └── gcode_parser.py ├── main.ipynb ├── main.py ├── requirements.txt ├── setup.py └── test ├── __init__.py ├── test_element_type.py ├── test_gcode_line.py ├── test_get_lines.py └── test_split_params.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.4 3 | commit = False 4 | tag = False 5 | allow_dirty = False 6 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))? 7 | serialize = 8 | {major}.{minor}.{patch}-{release}{build} 9 | {major}.{minor}.{patch} 10 | 11 | [bumpversion:part:release] 12 | optional_value = prod 13 | first_value = b 14 | values = 15 | b 16 | prod 17 | 18 | [bumpversion:part:build] 19 | 20 | [bumpversion:file:setup.py] 21 | 22 | [bumpversion:file:gcodeparser/__init__.py] 23 | replace = {new_version} 24 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: write 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install setuptools 33 | pip install -r requirements.txt 34 | - name: Test code 35 | run: pytest 36 | - name: Bumpversion 37 | run: bumpversion --new-version=${{ github.event.release.tag_name }} minor 38 | - name: Clean dist/ 39 | run: rm dist/* -f 40 | - name: Build package 41 | run: python setup.py sdist bdist_wheel 42 | - name: Check package 43 | run: python -m twine check dist/* 44 | - name: Commit version change 45 | run: | 46 | git config --global user.name 'Github Actions' 47 | git config --global user.email '38423143+AndyEveritt@users.noreply.github.com' 48 | git commit -am "Release ${{ github.event.release.name }}" 49 | git tag -f ${{ github.event.release.tag_name }} 50 | git push origin HEAD:master 51 | git push origin -f --tags 52 | - name: Publish package 53 | uses: pypa/gh-action-pypi-publish@0ab0b79471669eb3a4d647e625009c62f9f3b241 54 | with: 55 | user: __token__ 56 | password: ${{ secrets.PYPI_API_TOKEN }} 57 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/windows,python 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,python 5 | 6 | ### Python ### 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | pytestdebug.log 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | doc/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | pythonenv* 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | # pytype static type analyzer 141 | .pytype/ 142 | 143 | # profiling data 144 | .prof 145 | 146 | ### Windows ### 147 | # Windows thumbnail cache files 148 | Thumbs.db 149 | Thumbs.db:encryptable 150 | ehthumbs.db 151 | ehthumbs_vista.db 152 | 153 | # Dump file 154 | *.stackdump 155 | 156 | # Folder config file 157 | [Dd]esktop.ini 158 | 159 | # Recycle Bin used on file shares 160 | $RECYCLE.BIN/ 161 | 162 | # Windows Installer files 163 | *.cab 164 | *.msi 165 | *.msix 166 | *.msm 167 | *.msp 168 | 169 | # Windows shortcuts 170 | *.lnk 171 | 172 | # End of https://www.toptal.com/developers/gitignore/api/windows,python 173 | 174 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 175 | 176 | .vscode 177 | *.gcode 178 | *.csv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | verbosity=1 3 | 4 | ######################################### 5 | # bumpversion Usage 6 | ######################################### 7 | # `bumpversion [major|minor|patch|build]` 8 | # `bumpversion --tag release 9 | 10 | update_dist: 11 | python -m pytest 12 | rm dist/* -f 13 | python setup.py sdist bdist_wheel 14 | 15 | check_dist: update_dist 16 | python -m twine check dist/* 17 | 18 | upload_test: check_dist 19 | python -m twine upload --repository testpypi dist/* 20 | 21 | upload: check_dist 22 | python -m twine upload dist/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GcodeParser 2 | 3 | A simple gcode parser that takes a string of text and returns a list where each gcode command is seperated into a python object. 4 | 5 | The structure of the python object is: 6 | 7 | `G1 X10 Y-2.5 ; this is a comment` 8 | 9 | ```python 10 | GcodeLine( 11 | command = ('G', 1), 12 | params = {'X': 10, 'Y': -2.5}, 13 | comment = 'this is a comment', 14 | ) 15 | ``` 16 | 17 | # Install 18 | 19 | ``` 20 | pip install gcodeparser 21 | ``` 22 | 23 | Alternatively: 24 | 25 | ``` 26 | pip install -e "git+https://github.com/AndyEveritt/GcodeParser.git@master#egg=gcodeparser" 27 | ``` 28 | 29 | # Usage 30 | 31 | ```python 32 | from gcodeparser import GcodeParser 33 | 34 | # open gcode file and store contents as variable 35 | with open('my_gcode.gcode', 'r') as f: 36 | gcode = f.read() 37 | 38 | GcodeParser(gcode).lines # get parsed gcode lines 39 | ``` 40 | 41 | ## Include Comments 42 | 43 | `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. 44 | 45 | ```py 46 | gcode = ( 47 | 'G1 X1 ; this comment is always included\n', 48 | '; this comment will only be included if `include_comments=True`', 49 | ) 50 | 51 | GcodeParser(gcode, include_comments=True).lines 52 | ``` 53 | 54 | If `include_comments` is `True` then the comment line will be in the form of: 55 | 56 | ```python 57 | GcodeLine( 58 | command = (';', None), 59 | params = {}, 60 | comment = 'this comment will only be included if `include_comments=True`', 61 | ) 62 | ``` 63 | 64 | ## Converting a File 65 | 66 | ```python 67 | from gcodeparser import GcodeParser 68 | 69 | with open('3DBenchy.gcode', 'r') as f: 70 | gcode = f.read() 71 | parsed_gcode = GcodeParser(gcode) 72 | parsed_gcode.lines 73 | ``` 74 | 75 | _output:_ 76 | 77 | ```py 78 | [GcodeLine(command=('G', 10), params={'P': 0, 'R': 0, 'S': 0}, comment='sets the standby temperature'), 79 | GcodeLine(command=('G', 29), params={'S': 1}, comment=''), 80 | GcodeLine(command=('T', 0), params={}, comment=''), 81 | GcodeLine(command=('G', 21), params={}, comment='set units to millimeters'), 82 | GcodeLine(command=('G', 90), params={}, comment='use absolute coordinates'), 83 | GcodeLine(command=('M', 83), params={}, comment='use relative distances for extrusion'), 84 | GcodeLine(command=('G', 1), params={'E': -0.6, 'F': 3600.0}, comment=''), 85 | GcodeLine(command=('G', 1), params={'Z': 0.45, 'F': 7800.0}, comment=''), 86 | GcodeLine(command=('G', 1), params={'Z': 2.35}, comment=''), 87 | GcodeLine(command=('G', 1), params={'X': 119.575, 'Y': 89.986}, comment=''), 88 | GcodeLine(command=('G', 1), params={'Z': 0.45}, comment=''), 89 | GcodeLine(command=('G', 1), params={'E': 0.6, 'F': 3600.0}, comment=''), 90 | GcodeLine(command=('G', 1), params={'F': 1800.0}, comment=''), 91 | GcodeLine(command=('G', 1), params={'X': 120.774, 'Y': 88.783, 'E': 0.17459}, comment=''), 92 | GcodeLine(command=('G', 1), params={'X': 121.692, 'Y': 88.145, 'E': 0.11492}, comment=''), 93 | GcodeLine(command=('G', 1), params={'X': 122.7, 'Y': 87.638, 'E': 0.11596}, comment=''), 94 | GcodeLine(command=('G', 1), params={'X': 123.742, 'Y': 87.285, 'E': 0.11317}, comment=''), 95 | ... 96 | ] 97 | ``` 98 | 99 | ## Convert Command Tuple to String 100 | 101 | The `GcodeLine`class has a property `command_str` which will return the command tuple as a string. ie `('G', 91)` -> `"G91"`. 102 | 103 | ## Changing back to Gcode String 104 | 105 | The `GcodeLine` class has a property `gcode_str` which will return the equivalent gcode string. 106 | 107 | > This was called `to_gcode()` in version 0.0.6 and before. 108 | 109 | ## Parameters 110 | 111 | The `GcodeLine` class has a several helper methods to get and manipulate gcode parameters. 112 | 113 | For an example `GcodeLine` `line`: 114 | 115 | ### Retrieving Params 116 | 117 | To retrieve a param, use the method `get_param(param: str, return_type=None, default=None)` which 118 | returns the value of the param if it exists, otherwise it will the `default` value. 119 | If `return_type` is set, the return value will be type cast. 120 | 121 | ```python 122 | line.get_param('X') 123 | ``` 124 | 125 | ### Updating Params 126 | 127 | To update a param, use the method `update_param(param: str, value: int | float)` 128 | 129 | ```python 130 | line.update_param('X', 10) 131 | ``` 132 | 133 | If the param does not exist, it will return `None` else it will return the updated value. 134 | 135 | ### Deleting Params 136 | 137 | To delete a param, use the method `delete_param(param: str)` 138 | 139 | ```python 140 | line.delete_param('X') 141 | ``` 142 | 143 | ## Converting to DataFrames 144 | 145 | If for whatever reason you want to convert your list of `GcodeLine` objects into a pandas dataframe, simply use `pd.DataFrame(GcodeParser(gcode).lines)` 146 | -------------------------------------------------------------------------------- /gcodeparser/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.4" 2 | 3 | from .gcode_parser import GcodeParser, GcodeLine 4 | from .commands import Commands 5 | -------------------------------------------------------------------------------- /gcodeparser/commands.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Commands(Enum): 5 | COMMENT = 0 6 | MOVE = 1 7 | OTHER = 2 8 | TOOLCHANGE = 3 9 | -------------------------------------------------------------------------------- /gcodeparser/gcode_parser.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Tuple, Union 2 | from dataclasses import dataclass 3 | import re 4 | from .commands import Commands 5 | 6 | 7 | @dataclass 8 | class GcodeLine: 9 | command: Union[Tuple[str, int], Tuple[str, None]] 10 | params: Dict[str, Union[float, str]] 11 | comment: str 12 | 13 | def __post_init__(self): 14 | if self.command[0] == 'G' and self.command[1] in (0, 1, 2, 3): 15 | self.type = Commands.MOVE 16 | elif self.command[0] == ';': 17 | self.type = Commands.COMMENT 18 | elif self.command[0] == 'T': 19 | self.type = Commands.TOOLCHANGE 20 | else: 21 | self.type = Commands.OTHER 22 | 23 | @property 24 | def command_str(self): 25 | return f"{self.command[0]}{self.command[1] if self.command[1] is not None else ''}" 26 | 27 | def get_param(self, param: str, return_type=None, default=None): 28 | """ 29 | Returns the value of the param if it exists, otherwise it will the default value. 30 | If `return_type` is set, the return value will be type cast. 31 | """ 32 | try: 33 | if return_type is None: 34 | return self.params[param] 35 | else: 36 | return return_type(self.params[param]) 37 | except KeyError: 38 | return default 39 | 40 | def update_param(self, param: str, value: Union[int, float]): 41 | if self.get_param(param) is None: 42 | return 43 | if type(value) not in (int, float): 44 | raise TypeError(f"Type {type(value)} is not a valid parameter type") 45 | self.params[param] = value 46 | return self.get_param(param) 47 | 48 | def delete_param(self, param: str): 49 | if self.get_param(param) is None: 50 | return 51 | self.params.pop(param) 52 | 53 | @property 54 | def gcode_str(self): 55 | command = self.command_str 56 | 57 | def param_value(param): 58 | value = self.get_param(param) 59 | is_flag_parameter = value is True 60 | if is_flag_parameter: 61 | return "" 62 | return value 63 | 64 | params = " ".join(f"{param}{param_value(param)}" for param in self.params.keys()) 65 | comment = f"; {self.comment}" if self.comment != '' else "" 66 | if command == ';': 67 | return comment 68 | return f"{command} {params} {comment}".strip() 69 | 70 | 71 | class GcodeParser: 72 | def __init__(self, gcode: str, include_comments=False): 73 | self.gcode = gcode 74 | self.lines: List[GcodeLine] = get_lines(self.gcode, include_comments) 75 | self.include_comments = include_comments 76 | 77 | 78 | def get_lines(gcode, include_comments=False): 79 | regex = r'(?!; *.+)(G|M|T|g|m|t)(\d+)(([ \t]*(?!G|M|g|m)\w(".*"|([-+\d\.]*)))*)[ \t]*(;[ \t]*(.*))?|;[ \t]*(.+)' 80 | regex_lines = re.findall(regex, gcode) 81 | lines = [] 82 | for line in regex_lines: 83 | if line[0]: 84 | command = (line[0].upper(), int(line[1])) 85 | comment = line[-2] 86 | params = split_params(line[2]) 87 | 88 | elif include_comments: 89 | command = (';', None) 90 | comment = line[-1] 91 | params = {} 92 | 93 | else: 94 | continue 95 | 96 | lines.append( 97 | GcodeLine( 98 | command=command, 99 | params=params, 100 | comment=comment.strip(), 101 | )) 102 | 103 | return lines 104 | 105 | 106 | def element_type(element: str): 107 | if re.search(r'"', element): 108 | return str 109 | if re.search(r'\..*\.', element): 110 | return str 111 | if re.search(r'[+-]?\d*\.\d+', element): 112 | return float 113 | return int 114 | 115 | 116 | def split_params(line): 117 | regex = r'((?!\d)\w+?)(".*"|(\d+\.?)+|[-+]?\d*\.?\d*)' 118 | elements = re.findall(regex, line) 119 | params = {} 120 | for element in elements: 121 | if element[1] == '': 122 | params[element[0].upper()] = True 123 | continue 124 | params[element[0].upper()] = element_type(element[1])(element[1]) 125 | 126 | return params 127 | 128 | 129 | if __name__ == '__main__': 130 | with open('3DBenchy.gcode', 'r') as f: 131 | gcode = f.read() 132 | parsed_gcode = GcodeParser(gcode) 133 | pass 134 | -------------------------------------------------------------------------------- /main.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from gcodeparser import GcodeParser, GcodeLine" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 96, 15 | "metadata": {}, 16 | "outputs": [ 17 | { 18 | "data": { 19 | "text/plain": [ 20 | "[GcodeLine(command=('M', 552), params={'P': '01.0.0.0', 'S': 1}, comment=''),\n", 21 | " GcodeLine(command=('M', 550), params={'P': '\"ForceRig; \"'}, comment=''),\n", 22 | " GcodeLine(command=('G', 21), params={'P': 0}, comment=''),\n", 23 | " GcodeLine(command=('G', 1), params={'X': 10}, comment=''),\n", 24 | " GcodeLine(command=('G', 1), params={'Z': -0.5, 'F': 600.0}, comment=''),\n", 25 | " GcodeLine(command=('G', 1), params={'X': 150.78, 'Y': 88.675, 'F': 9000.0}, comment=''),\n", 26 | " GcodeLine(command=('G', 1), params={'Z': 0.2, 'F': 600.0}, comment=''),\n", 27 | " GcodeLine(command=('G', 1), params={'E': 0.8, 'F': 6000.0}, comment=''),\n", 28 | " GcodeLine(command=('G', 1), params={'X': 152.506, 'Y': 88.828, 'E': 0.0692, 'F': 2400.0}, comment=''),\n", 29 | " GcodeLine(command=('G', 1), params={'X': 152.642, 'Y': 88.846, 'E': 0.0055}, comment=''),\n", 30 | " GcodeLine(command=('M', 116), params={}, comment='wait for temps to settle'),\n", 31 | " GcodeLine(command=('T', 0), params={}, comment=''),\n", 32 | " GcodeLine(command=('G', 90), params={}, comment=''),\n", 33 | " GcodeLine(command=('M', 83), params={}, comment='')]" 34 | ] 35 | }, 36 | "execution_count": 96, 37 | "metadata": {}, 38 | "output_type": "execute_result" 39 | } 40 | ], 41 | "source": [ 42 | "with open('3DBenchy.gcode', 'r') as f:\n", 43 | " gcode = f.read()\n", 44 | "parsed_gcode = GcodeParser(gcode)\n", 45 | "parsed_gcode.lines[0:14]" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": 95, 51 | "metadata": {}, 52 | "outputs": [ 53 | { 54 | "data": { 55 | "text/plain": [ 56 | "{'Y': 88.675, 'F': 9000.0}" 57 | ] 58 | }, 59 | "execution_count": 95, 60 | "metadata": {}, 61 | "output_type": "execute_result" 62 | } 63 | ], 64 | "source": [ 65 | "parsed_gcode.lines[5].params" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": 82, 71 | "metadata": {}, 72 | "outputs": [ 73 | { 74 | "name": "stdout", 75 | "output_type": "stream", 76 | "text": [ 77 | "('', '', '', '', '', '', '', '', 'Networking')\n", 78 | "('M', '552', ' P0.0.0.0 S1', ' S1', '1', '1', '', '', '')\n", 79 | "('M', '550', ' P\"ForceRig; \"', ' P\"ForceRig; \"', '\"ForceRig; \"', '', '', '', '')\n", 80 | "('', '', '', '', '', '', '', '', 'General preferences')\n", 81 | "('G', '21', '', '', '', '', '', '', '')\n", 82 | "('G', '1', ' X10', ' X10', '10', '10', '', '', '')\n", 83 | "('G', '1', ' Z-0.500 F600', ' F600', '600', '600', '', '', '')\n", 84 | "('G', '1', ' X150.780 Y88.675 F9000', ' F9000', '9000', '9000', '', '', '')\n", 85 | "('G', '1', ' Z0.200 F600', ' F600', '600', '600', '', '', '')\n", 86 | "('G', '1', ' E0.8000 F6000', ' F6000', '6000', '6000', '', '', '')\n", 87 | "('G', '1', ' X152.506 Y88.828 E0.0692 F2400', ' F2400', '2400', '2400', '', '', '')\n", 88 | "('G', '1', ' X152.642 Y88.846 E0.0055', ' E0.0055', '0.0055', '0.0055', '', '', '')\n", 89 | "('M', '116', '', '', '', '', '; wait for temps to settle', 'wait for temps to settle', '')\n", 90 | "('T', '0', '', '', '', '', '', '', '')\n" 91 | ] 92 | } 93 | ], 94 | "source": [ 95 | "import re\n", 96 | "\n", 97 | "regex = r'(?!; *.+)(G|M|T|g|m|t)(\\d+)(( *(?!G|M|g|m)\\w(((-?\\d+\\.?)*)|\".*\"))*) *\\t*(; *(.*))?|; *(.+)'\n", 98 | "regex = r'(?!; *.+)(G|M|T|g|m|t)(\\d+)(( *(?!G|M|g|m)\\w(\".*\"|([-\\d\\.]*)))*) *\\t*(; *(.*))?|; *(.+)'\n", 99 | "# regex = r'(?!; *.+)(G|M|T|g|m|t)(\\d+)( *(?!G|M|g|m)[^;\\n]*) *\\t*(; *\\t*(.*))?|; *(.+)'\n", 100 | "regex_lines = re.findall(regex, gcode)\n", 101 | "\n", 102 | "for line in regex_lines[0:14]:\n", 103 | " print(line)\n", 104 | " print(re.findall(r'((?!\\d)\\w+?)(\".*\"|(\\d+\\.?)+|-?\\d+\\.?\\d*)', line[2]))\n" 105 | ] 106 | } 107 | ], 108 | "metadata": { 109 | "kernelspec": { 110 | "display_name": "Python 3", 111 | "language": "python", 112 | "name": "python3" 113 | }, 114 | "language_info": { 115 | "codemirror_mode": { 116 | "name": "ipython", 117 | "version": 3 118 | }, 119 | "file_extension": ".py", 120 | "mimetype": "text/x-python", 121 | "name": "python", 122 | "nbconvert_exporter": "python", 123 | "pygments_lexer": "ipython3", 124 | "version": "3.7.6" 125 | }, 126 | "orig_nbformat": 2 127 | }, 128 | "nbformat": 4, 129 | "nbformat_minor": 2 130 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from gcodeparser import GcodeParser 4 | 5 | 6 | if __name__ == '__main__': 7 | parser = argparse.ArgumentParser( 8 | prog='GcodeParser', 9 | description='Converts a gcode file into an array of Python objects representing each command', 10 | ) 11 | 12 | parser.add_argument( 13 | 'input_file', 14 | help='Path to the gcode file to be parsed', 15 | ) 16 | 17 | parser.add_argument( 18 | '-o', 19 | '--output_file', 20 | help='Path to the output file where the parsed gcode will be saved. Defaults to stdout.', 21 | default='-', 22 | ) 23 | 24 | args = parser.parse_args() 25 | 26 | with open(os.path.expanduser(args.input_file), 'r') as f: 27 | gcode = f.read() 28 | parsed_gcode = GcodeParser(gcode) 29 | if args.output_file == '-': 30 | for line in parsed_gcode.lines: 31 | print(line) 32 | else: 33 | with open(os.path.expanduser(args.output_file), 'w') as f: 34 | for line in parsed_gcode.lines: 35 | f.write(str(line) + '\n') 36 | pass 37 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | wheel 3 | twine 4 | bump2version -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | import pathlib 3 | 4 | # The directory containing this file 5 | HERE = pathlib.Path(__file__).parent 6 | 7 | # The text of the README file 8 | README = (HERE / "README.md").read_text() 9 | 10 | setup( 11 | name="gcodeparser", 12 | version="0.2.4", 13 | include_package_data=True, 14 | packages=find_packages(), 15 | 16 | install_requires=[ 17 | ], 18 | 19 | author="Andy Everitt", 20 | author_email="andreweveritt@e3d-online.com", 21 | description="Python gcode parser", 22 | long_description=README, 23 | long_description_content_type="text/markdown", 24 | url="https://github.com/AndyEveritt/GcodeParser", 25 | classifiers=[ 26 | "License :: OSI Approved :: MIT License", 27 | "Programming Language :: Python :: 3", 28 | "Operating System :: OS Independent", 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndyEveritt/GcodeParser/ffe39b4b9a2de1c1e96f270a831500ab84c4fbce/test/__init__.py -------------------------------------------------------------------------------- /test/test_element_type.py: -------------------------------------------------------------------------------- 1 | from gcodeparser.gcode_parser import ( 2 | element_type, 3 | ) 4 | 5 | 6 | def test_element_type_int(): 7 | assert element_type('109321') == int 8 | 9 | 10 | def test_element_type_neg_int(): 11 | assert element_type('-109321') == int 12 | 13 | 14 | def test_element_type_float(): 15 | assert element_type('109321.0') == float 16 | 17 | 18 | def test_element_type_float2(): 19 | assert element_type('109321.012345') == float 20 | 21 | 22 | def test_element_type_neg_float(): 23 | assert element_type('-1.0') == float 24 | 25 | 26 | def test_element_type_neg_float2(): 27 | assert element_type('-1.013456') == float 28 | 29 | 30 | def test_element_type_str(): 31 | assert element_type('192.168.0.1') == str 32 | 33 | 34 | def test_element_type_str2(): 35 | assert element_type('"test string"') == str 36 | -------------------------------------------------------------------------------- /test/test_gcode_line.py: -------------------------------------------------------------------------------- 1 | from gcodeparser.gcode_parser import ( 2 | GcodeLine, 3 | GcodeParser, 4 | get_lines, 5 | element_type, 6 | split_params, 7 | ) 8 | from gcodeparser.commands import Commands 9 | 10 | 11 | def test_post_init_move(): 12 | line = GcodeLine( 13 | command=('G', 1), 14 | params={'X': 10, 'Y': 20}, 15 | comment='this is a comment', 16 | ) 17 | assert line.type == Commands.MOVE 18 | 19 | 20 | def test_post_init_toolchange(): 21 | line = GcodeLine( 22 | command=('T', 1), 23 | params={}, 24 | comment='this is a comment', 25 | ) 26 | assert line.type == Commands.TOOLCHANGE 27 | 28 | 29 | def test_post_init_other(): 30 | line = GcodeLine( 31 | command=('G', 91), 32 | params={'X': 10, 'Y': 20}, 33 | comment='this is a comment', 34 | ) 35 | assert line.type == Commands.OTHER 36 | 37 | 38 | def test_command_str(): 39 | line = GcodeLine( 40 | command=('G', 91), 41 | params={'X': 10, 'Y': 20}, 42 | comment='this is a comment', 43 | ) 44 | assert line.command_str == 'G91' 45 | 46 | 47 | def test_to_gcode(): 48 | line = GcodeLine( 49 | command=('G', 91), 50 | params={'X': 10, 'Y': 20}, 51 | comment='this is a comment', 52 | ) 53 | assert line.gcode_str == 'G91 X10 Y20 ; this is a comment' 54 | 55 | 56 | def test_flag_parameter_to_gcode(): 57 | line = GcodeLine( 58 | command=('G', 28), 59 | params={'X': True}, 60 | comment='', 61 | ) 62 | assert line.gcode_str == 'G28 X' 63 | 64 | 65 | def test_flag_parameter_2_to_gcode(): 66 | line = GcodeLine( 67 | command=('G', 28), 68 | params={'X': True, 'Y': 1}, 69 | comment='', 70 | ) 71 | assert line.gcode_str == 'G28 X Y1' 72 | -------------------------------------------------------------------------------- /test/test_get_lines.py: -------------------------------------------------------------------------------- 1 | from gcodeparser.gcode_parser import ( 2 | GcodeLine, 3 | GcodeParser, 4 | get_lines, 5 | element_type, 6 | split_params, 7 | ) 8 | from gcodeparser.commands import Commands 9 | 10 | 11 | def test_no_params(): 12 | line = GcodeLine( 13 | command=('G', 21), 14 | params={}, 15 | comment='', 16 | ) 17 | assert get_lines('G21')[0] == line 18 | 19 | 20 | def test_params(): 21 | line = GcodeLine( 22 | command=('G', 1), 23 | params={'X': 10, 'Y': 20}, 24 | comment='', 25 | ) 26 | assert get_lines('G1 X10 Y20')[0] == line 27 | 28 | 29 | def test_params_with_explicit_positive_values(): 30 | line = GcodeLine( 31 | command=('G', 1), 32 | params={'X': 10, 'Y': 20}, 33 | comment='', 34 | ) 35 | assert get_lines('G1 X+10 Y+20')[0] == line 36 | 37 | 38 | def test_2_commands_line(): 39 | line1 = GcodeLine( 40 | command=('G', 91), 41 | params={}, 42 | comment='', 43 | ) 44 | line2 = GcodeLine( 45 | command=('G', 1), 46 | params={'X': 10, 'Y': 20}, 47 | comment='', 48 | ) 49 | lines = get_lines('G91 G1 X10 Y20') 50 | assert lines[0] == line1 51 | assert lines[1] == line2 52 | 53 | 54 | def test_string_params(): 55 | line = GcodeLine( 56 | command=('M', 550), 57 | params={'P': '"hostname"'}, 58 | comment='', 59 | ) 60 | assert get_lines('M550 P"hostname"')[0] == line 61 | 62 | 63 | def test_ip_address_params(): 64 | line = GcodeLine( 65 | command=('M', 552), 66 | params={'P': '192.168.0.1', 'S': 1}, 67 | comment='', 68 | ) 69 | assert get_lines('M552 P192.168.0.1 S1')[0] == line 70 | 71 | 72 | def test_inline_comment(): 73 | line = GcodeLine( 74 | command=('G', 1), 75 | params={'X': 10, 'Y': 20}, 76 | comment='this is a comment', 77 | ) 78 | assert get_lines('G1 X10 Y20 ; this is a comment')[0] == line 79 | assert get_lines('G1 X10 Y20 ; this is a comment')[0] == line 80 | assert get_lines('G1 X10 Y20 \t; \t this is a comment')[0] == line 81 | assert get_lines('G1 X10 Y20 \t; \t this is a comment')[0] == line 82 | 83 | 84 | def test_inline_comment2(): 85 | line = GcodeLine( 86 | command=('G', 1), 87 | params={'X': 10, 'Y': 20}, 88 | comment='this is a comment ; with a dummy comment for bants', 89 | ) 90 | assert get_lines('G1 X10 Y20 ; this is a comment ; with a dummy comment for bants')[0] == line 91 | 92 | 93 | def test_include_comment_true(): 94 | line = GcodeLine( 95 | command=(';', None), 96 | params={}, 97 | comment='this is a comment', 98 | ) 99 | assert get_lines('; this is a comment', include_comments=True)[0] == line 100 | 101 | 102 | def test_include_comment_false(): 103 | assert len(get_lines('; this is a comment', include_comments=False)) == 0 104 | 105 | 106 | def test_multi_line(): 107 | lines = [ 108 | GcodeLine( 109 | command=('G', 91), 110 | params={}, 111 | comment='', 112 | ), GcodeLine( 113 | command=('G', 1), 114 | params={'X': -10, 'Y': 20}, 115 | comment='inline comment', 116 | ), GcodeLine( 117 | command=('G', 1), 118 | params={'Z': 0.5}, 119 | comment='', 120 | ), GcodeLine( 121 | command=('T', 1), 122 | params={}, 123 | comment='', 124 | ), GcodeLine( 125 | command=('M', 350), 126 | params={'T': 100}, 127 | comment='', 128 | )] 129 | assert get_lines('G91\nG1 X-10 Y20 ; inline comment\nG1 Z0.5\nT1\nM350 T100') == lines 130 | assert get_lines('G91G1 X-10 Y20;inline comment\nG1 Z0.5\nT1M350 T100') == lines 131 | assert get_lines(' \tG91\n\tG1\t X-10 Y20 \t ;\t inline comment\nG1 Z0.5\nT1\nM350 T100') == lines 132 | assert get_lines('G91\nG1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100', 133 | include_comments=False) == lines 134 | assert get_lines('G91 G1 X-10 Y20 ; inline comment\n; comment to be excluded\nG1 Z0.5\nT1\nM350 T100', 135 | include_comments=False) == lines 136 | 137 | 138 | def test_multi_line2(): 139 | """ We want to ignore things that look like gcode in the comments 140 | """ 141 | lines = [ 142 | GcodeLine( 143 | command=('G', 91), 144 | params={}, 145 | comment='', 146 | ), 147 | GcodeLine( 148 | command=('G', 1), 149 | params={'X': 100}, 150 | comment='comment G90' 151 | ) 152 | ] 153 | assert get_lines('G91 G1 X100 ; comment G90') == lines 154 | -------------------------------------------------------------------------------- /test/test_split_params.py: -------------------------------------------------------------------------------- 1 | from gcodeparser.gcode_parser import ( 2 | GcodeLine, 3 | GcodeParser, 4 | get_lines, 5 | element_type, 6 | split_params, 7 | ) 8 | from gcodeparser.commands import Commands 9 | 10 | 11 | def test_split_int_params(): 12 | assert split_params(' P0 S1 X10') == {'P': 0, 'S': 1, 'X': 10} 13 | 14 | 15 | def test_split_float_params(): 16 | assert split_params(' P0.1 S1.1345 X10.0') == {'P': 0.1, 'S': 1.1345, 'X': 10.0} 17 | 18 | 19 | def test_split_sub1_params(): 20 | assert split_params(' P0.00001 S-0.00021 X.0001 Y-.003213') == {'P': 0.00001, 'S': -0.00021, 'X': 0.0001, 'Y': -0.003213} 21 | 22 | 23 | def test_split_string_params(): 24 | assert split_params(' P"string"') == {'P': '"string"'} 25 | 26 | 27 | def test_split_string_with_semicolon_params(): 28 | assert split_params(' P"string ; semicolon"') == {'P': '"string ; semicolon"'} 29 | 30 | 31 | def test_split_neg_int_params(): 32 | assert split_params(' P-0 S-1 X-10') == {'P': 0, 'S': -1, 'X': -10} 33 | 34 | def test_split_positive_int_params(): 35 | assert split_params(' P+0 S+1 X+10') == {'P': 0, 'S': 1, 'X': 10} 36 | 37 | 38 | def test_split_neg_float_params(): 39 | assert split_params(' P-0.1 S-1.1345 X-10.0') == {'P': -0.1, 'S': -1.1345, 'X': -10.0} 40 | 41 | def test_split_positive_float_params(): 42 | assert split_params(' P+0.1 S+1.1345 X+10.0') == {'P': 0.1, 'S': 1.1345, 'X': 10.0} 43 | 44 | 45 | def test_split_ip_params(): 46 | assert split_params('P192.168.0.1 S1') == {'P': '192.168.0.1', 'S': 1} 47 | 48 | 49 | def test_split_no_space_params(): 50 | assert split_params('P0.1S1.1345X10.0A"string"') == {'P': 0.1, 'S': 1.1345, 'X': 10.0, 'A': '"string"'} 51 | 52 | def test_split_no_value_params(): 53 | assert split_params(' X') == {'X': True} 54 | 55 | def test_split_multi_no_value_params(): 56 | assert split_params(' XYZ') == {'X': True, 'Y': True, 'Z': True} 57 | 58 | def test_split_multi_no_value_spaced_params(): 59 | assert split_params(' X Y Z') == {'X': True, 'Y': True, 'Z': True} 60 | --------------------------------------------------------------------------------