A repository to host code and a bunch of other stuff. These have to do with our efforts to repurpose a Prusa i3 MKRS3+ 3D printer to serve as a DIY DNA microarray spotter so that we can put S. cerevisiae cells inside a PDMS Lab-On-a-Chip (check here too!).
Click to expand
A cross-platform GUI bundled as an executable to help quickly adjust G-Code files that contain spotting instructions.
When using G-Code generated by a splicer, it might contain vendor-specific types.
These might not be parsed by pygcode
, resulting in an error when attempting to load the G-Code.
For example, generating files with the PrusaSlicer, I've currently gotten:
word 'P' value invalid
, solved by commentingM862.3 P "MK3S" ; printer model check
block code remaining '.1'
, solved by commentingM115 U3.10.1 ; tell printer latest fw version
word 'W' value invalid
, solved by commentingG28 W ; home all without mesh bed level
In general, I advise you remove the vendor-specific header and trailer and re-add it after you're done editing, if you'd like.
Click to expand
./.github/workflows
└─ ci.yml
./assets/
├─ minus.png
├─ multiply.png
├─ plus.png
├─ replace.png
└─ undo.png
./src/
├─ config_model.py
├─ config.toml
├─ eltypes.py
├─ GCodeUtils.py
├─ GUI.py
├─ highlighter.py
├─ IOUtils.py
├─ main.py
├─ operators.py
└─ paths.py
.editorconfig
add-files-to-spec
poetry.lock
poetry.toml
pyproject.toml
All CI magic happens using GitHub Actions.
The related configuration is all located within .github/workflows/ci.yml
:
Click to expand
name: CI
run-name: ${{ github.actor }} is running 🚀
on: [push] # Triggered by push.
jobs:
ci:
strategy:
fail-fast: false # Don't fail all jobs if a single job fails.
matrix:
python-version: ["3.11"]
poetry-version: ["1.2.2"] # Poetry is used for project/dependency management.
os: [ubuntu-latest, macos-latest, windows-latest]
include: # Where pip stores its cache is OS-dependent.
- pip-cache-path: ~/.cache
os: ubuntu-latest
- pip-cache-path: ~/.cache
os: macos-latest
- pip-cache-path: ~\appdata\local\pip\cache
os: windows-latest
defaults:
run:
shell: bash # For sane consistent scripting throughout.
runs-on: ${{ matrix.os }} # For each OS:
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Setup Python
id: setup-python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: ${{ matrix.poetry-version }}
virtualenvs-create: true
virtualenvs-in-project: true # Otherwise the venv will be the same across all OSes.
installer-parallel: true
- name: Load cached venv
id: cached-pip-wheels
uses: actions/cache@v3
with:
path: ${{ matrix.pip-cache-path }}
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install dependencies
run: poetry install --no-interaction --no-root -E build -E format # https://github.com/python-poetry/poetry/issues/1227
- name: Check formatting
run: |
source $VENV
yapf -drp --no-local-style --style "facebook" src/
- name: Build for ${{ matrix.os }}
run: | # https://stackoverflow.com/questions/19456518/error-when-using-sed-with-find-command-on-os-x-invalid-command-code
source $VENV
pyi-makespec src/main.py
if [ "$RUNNER_OS" == "macOS" ]; then
sed -i '' -e '2 r add-files-to-spec' main.spec
sed -i '' -e 's/datas=\[]/datas=added_files/' main.spec
else
sed -i '2 r add-files-to-spec' main.spec
sed -i 's/datas=\[]/datas=added_files/' main.spec
fi
pyinstaller main.spec
- name: Archive binary artifacts
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.os }}-bundle
path: dist
On each push, the application is bundled into a single folder containing an executable, for each OS.
This happens using pyinstaller
.
First there's a formatting check using yapf
.
Then, the application is built.
pytest
is included as an extra optional dependency to add unit test support in the future.
Everything is cached when possible.
If the job terminates successfully, the bundle folder for each OS is uploaded as an artifact that the user can download, instead of having to run pyinstaller
locally, or having to install python
and the project dependencies locally through poetry
.
Icons:
- Replace icon by Icons8
- Plus Math icon by Icons8
- Subtract icon by Icons8
- Multiply icon by Icons8
- Return icon by Icons8
- Delete Key icon by Icons8
main.py
is the entrypoint to be run:
Click to expand
from sys import exit
from PySide6.QtWidgets import QApplication
from GUI import GCodeUtilsGUI
from IOUtils import read_config
from paths import get_path
if __name__ == "__main__":
relative_paths = True
CONFIG = read_config(get_path("config", relative_paths))
if not CONFIG:
exit(1)
WINDOW_CONFIG = CONFIG.window
app = QApplication([])
window = GCodeUtilsGUI(CONFIG, relative_paths)
window.resize(
WINDOW_CONFIG["dimension"]["width"],
WINDOW_CONFIG["dimension"]["height"]
)
window.show()
exit(app.exec())
It creates the GUI (using PySide6
) main application.
Configuration for the GUI is stored in a separate TOML file, config.toml
.
The config model is verified using pydantic
.
The GUI is the backbone of the application, and it's described in GUI.py
:
Click to expand
from collections import deque
from copy import deepcopy
import logging
from pathlib import Path
from PySide6.QtCore import QSize, Slot
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QMainWindow, QLabel, QPlainTextEdit, QVBoxLayout, QWidget, QToolBar,
QPushButton, QStatusBar, QGroupBox, QHBoxLayout, QComboBox, QDoubleSpinBox,
QFrame, QCheckBox, QFileDialog
)
from eltypes import config, operator
from GCodeUtils import dec_coor, inc_coor, replace_coor
from highlighter import Highlighter
from IOUtils import lines_to_text, read_gcode, text_to_lines, write_gcode
from paths import get_path
class GCodeUtilsGUI(QMainWindow):
def __init__(self, config: config, relative_paths: bool):
super().__init__()
ICON_CONFIG = config.icon
COOR_CONFIG = config.coordinate
self._init_ui(COOR_CONFIG, ICON_CONFIG, relative_paths)
def _init_ui(
self, coor_config: config, icon_config: config, relative_paths: bool
) -> None:
selector_threshold = coor_config["threshold"]
self._create_io_group_box()
self._create_coor_group_box(selector_threshold)
self._create_coor_frame_separator()
self._create_new_val_group_box(selector_threshold)
self._create_additive_group_box()
self.selected_gcode_path = QLabel(self.tr("Selected G-Code: "))
self.gcode_viewer = QPlainTextEdit()
self.gcode_viewer.setReadOnly(True)
self.highlighter = Highlighter(self.gcode_viewer.document())
main_layout = QVBoxLayout()
main_layout.addWidget(self._io_group_box)
main_layout.addWidget(self.selected_gcode_path)
main_layout.addWidget(self.gcode_viewer)
main_layout.addWidget(self._coor_group_box)
main_layout.addWidget(self._frame_separator)
main_layout.addWidget(self._new_val_group_box)
main_layout.addWidget(self._additive_group_box)
self.setLayout(main_layout)
self.setWindowTitle(self.tr("Lab-On-a-Chip Spotting Utilties"))
# To have widgets appear.
dummy_widget = QWidget()
dummy_widget.setLayout(main_layout)
self.setCentralWidget(dummy_widget)
toolbar = QToolBar("Edit")
toolbar.setIconSize(
QSize(
icon_config["dimension"]["width"],
icon_config["dimension"]["height"]
)
)
self.addToolBar(toolbar)
plus_button = QPushButton(
QIcon(str(get_path("assets-plus", relative_paths))), "", self
)
plus_button.setStatusTip(
self.tr("Increase X/Y/Z G-Code coordinates by value")
)
plus_button.clicked.connect(self._handle_plus_button)
toolbar.addWidget(plus_button)
minus_button = QPushButton(
QIcon(str(get_path("assets-minus", relative_paths))), "", self
)
minus_button.setStatusTip(
self.tr("Decrease X/Y/Z G-Code coordinates by value")
)
minus_button.clicked.connect(self._handle_minus_button)
toolbar.addWidget(minus_button)
toolbar.addSeparator()
replace_button = QPushButton(
QIcon(str(get_path("assets-replace", relative_paths))), "", self
)
replace_button.setStatusTip(
self.tr("Replace X/Y/Z G-Code coordinates with value")
)
replace_button.clicked.connect(self._handle_replace_button)
toolbar.addWidget(replace_button)
toolbar.addSeparator()
delete_button = QPushButton(
QIcon(str(get_path("assets-delete", relative_paths))), "", self
)
delete_button.setStatusTip(self.tr("Delete selection"))
delete_button.clicked.connect(self._handle_delete_button)
toolbar.addWidget(delete_button)
replicate_button = QPushButton(
QIcon(str(get_path("assets-multiply", relative_paths))), "", self
)
replicate_button.setStatusTip(self.tr("Replicate selection"))
replicate_button.clicked.connect(self._handle_replicate_button)
toolbar.addWidget(replicate_button)
toolbar.addSeparator()
undo_button = QPushButton(
QIcon(str(get_path("assets-undo", relative_paths))), "", self
)
undo_button.setStatusTip(self.tr("Undo last G-Code operation"))
undo_button.clicked.connect(self._handle_undo_button)
toolbar.addWidget(undo_button)
self.setStatusBar(QStatusBar(self))
self.gcode = None
self.previous_gcodes = deque()
def _apply_coor_operator(self, op: operator) -> None:
if self.gcode is not None:
coor = self._coor_dropdown.currentText()
new_val = self._new_coor_val.value()
additive = self._additive_checkbox.isChecked()
specific_val = self._specific_val_selector.value(
) if self._specific_val_checkbox.isChecked() else None
new = []
times = 1
for line in self.gcode:
new_line, found = op(
line, coor, new_val * times, additive, specific_val
)
if found:
times += 1
new.append(new_line)
self.gcode = new
@Slot()
def _handle_plus_button(self):
self._save_last_gcode()
self._apply_coor_operator(inc_coor)
self._update_gcode_viewer()
@Slot()
def _handle_minus_button(self):
self._save_last_gcode()
self._apply_coor_operator(dec_coor)
self._update_gcode_viewer()
@Slot()
def _handle_replace_button(self):
self._save_last_gcode()
self._apply_coor_operator(replace_coor)
self._update_gcode_viewer()
@Slot()
def _handle_delete_button(self):
cursor = self.gcode_viewer.textCursor()
sel_start = cursor.selectionStart()
sel_end = cursor.selectionEnd()
if sel_end == 0:
return
text = self.gcode_viewer.toPlainText().rstrip()
if sel_start != 0:
if text[sel_start - 1] != '\n':
last_newline = text.rfind('\n', 0, sel_start - 1)
sel_start = last_newline + 1 if last_newline != -1 else 0
text_length = len(text)
if sel_end >= text_length:
sel_end = text_length - 1
if text[sel_end] != '\n':
if text[sel_end - 1] != '\n':
next_newline = text.find('\n', sel_end)
sel_end = next_newline + 1 if next_newline != -1 else text_length
else:
sel_end += 1
new_text = text[:sel_start] + text[sel_end:]
self._save_last_gcode()
self._update_gcode_from_text(new_text)
self._update_gcode_viewer()
@Slot()
def _handle_replicate_button(self):
cursor = self.gcode_viewer.textCursor()
sel_start = cursor.selectionStart()
sel_end = cursor.selectionEnd()
if sel_end == 0:
return
text = self.gcode_viewer.toPlainText()
sel_text = cursor.selection().toPlainText().rstrip() + '\n'
val = self._new_coor_val.value()
times = int(val) if val > 0 else 1
new_text = text[:sel_start] + sel_text * times + text[sel_start:]
self._save_last_gcode()
self._update_gcode_from_text(new_text)
self._update_gcode_viewer()
@Slot()
def _handle_undo_button(self) -> None:
if self.previous_gcodes:
self.gcode = self.previous_gcodes.pop()
self._update_gcode_viewer()
def _create_io_group_box(self) -> None:
self._io_group_box = QGroupBox(self.tr("IO"))
layout = QHBoxLayout()
browse_button = QPushButton(self.tr("Browse"))
browse_button.clicked.connect(self._browse_gcode)
save_button = QPushButton(self.tr("Save"))
save_button.clicked.connect(self._save_gcode)
layout.addWidget(browse_button)
layout.addWidget(save_button)
self._io_group_box.setLayout(layout)
def _create_coor_group_box(self, selector_threshold: config) -> None:
self._coor_group_box = QGroupBox(
self.tr("Select coordinate/operator value")
)
layout = QHBoxLayout()
self._coor_dropdown = QComboBox()
self._coor_dropdown.addItems(['X', 'Y', 'Z'])
self._new_coor_val = QDoubleSpinBox()
self._new_coor_val.setRange(
selector_threshold["min"], selector_threshold["max"]
)
layout.addWidget(self._coor_dropdown)
layout.addWidget(self._new_coor_val)
self._coor_group_box.setLayout(layout)
def _create_coor_frame_separator(self) -> None:
frame = QFrame()
self._frame_separator = frame
def _create_new_val_group_box(self, selector_threshold: config) -> None:
self._new_val_group_box = QGroupBox(
self.tr("Only change specific value")
)
layout = QHBoxLayout()
self._specific_val_checkbox = QCheckBox(self.tr("Specific value only"))
self._specific_val_selector = QDoubleSpinBox()
self._specific_val_selector.setRange(
selector_threshold["min"], selector_threshold["max"]
)
layout.addWidget(self._specific_val_checkbox)
layout.addWidget(self._specific_val_selector)
self._new_val_group_box.setLayout(layout)
def _create_additive_group_box(self) -> None:
self._additive_group_box = QGroupBox(
self.tr("Apply coordinate operations in additive manner")
)
layout = QHBoxLayout()
self._additive_checkbox = QCheckBox(
self.tr("Additive operation application")
)
layout.addWidget(self._additive_checkbox)
self._additive_group_box.setLayout(layout)
def _browse_gcode(self) -> None:
dialog = QFileDialog(self)
dialog.setFileMode(QFileDialog.ExistingFile)
dialog.setViewMode(QFileDialog.List)
dialog.setNameFilter(self.tr("G-Code (*.gcode)"))
if dialog.exec():
gcode_filename = dialog.selectedFiles()[0]
gcode_filename = Path(gcode_filename)
self.selected_gcode_path.setText(
self.tr(f"Selected G-Code: {gcode_filename.name}")
)
self.gcode = read_gcode(gcode_filename)
self._update_gcode_viewer()
logging.info(f"Loaded file {gcode_filename.name} successfully.")
def _save_gcode(self) -> None:
dialog = QFileDialog(self)
dialog.setFileMode(QFileDialog.AnyFile)
dialog.setViewMode(QFileDialog.List)
dialog.setAcceptMode(QFileDialog.AcceptSave)
dialog.setDefaultSuffix(self.tr("gcode"))
dialog.setNameFilter(self.tr("G-Code (*.gcode)"))
if dialog.exec():
gcode_filename = dialog.selectedFiles()[0]
write_gcode(gcode_filename, self.gcode)
logging.info(f"Saved file {gcode_filename.name} successfully.")
def _update_gcode_viewer(self) -> None:
if self.gcode is not None:
self.gcode_viewer.setPlainText(lines_to_text(self.gcode))
def _update_gcode_from_text(self, text: str) -> None:
self.gcode = text_to_lines(text)
def _save_last_gcode(self) -> None:
if self.gcode is not None:
self.previous_gcodes.append(deepcopy(self.gcode))
Essentially, there's:
- A toolbar
- Various buttons
- Selectors
- A reader
- A G-Code syntax highlighter
A G-Code can be loaded into the app through the Browse
button, and saved through the Save
button, respectively.
After a G-Code file is loaded, its absolute path is logged and its content is put in the reader for the user to browse.
The reader supports the following keybinds:
Keybind | Description |
---|---|
UpArrow |
Moves one line up |
DownArrow |
Moves one line down |
LeftArrow |
Moves one character to the left |
RightArrow |
Moves one character to the right |
PageUp |
Moves one (viewport) page up |
PageDown |
Moves one (viewport) page down |
Home |
Moves to the beginning of the G-Code |
End |
Moves to the end of the G-Code |
Alt+Wheel |
Scrolls the page horizontally |
Ctrl+Wheel |
Zooms the G-Code |
Ctrl+A |
Selects all text |
The application supports proper G-Code parsing, through pygcode
.
There are four G-Code operations supported:
- Add: Increase coordinate(s) by specified amount (plus button)
- Sub: Decrease coordinate(s) by specified amount (minus button)
- Replace: Set coordinate(s) to new value (replace button)
- Replicate: Replicated selected text X times (multiply button)
- Delete: Delete selected text (deletes the lines that contain the selection to avoid G-Code parsing errors)
The user can select whether to apply the operations to the X
, Y
or Z
coordinates.
Additionally, instead of applying an operations to every X|Y|Z
coordinate value, the user can instead only apply it to the X|Y|Z
coordinate that are of a specified value.
Also, an operator can be applied "additively" (makes sense for Add
and Sub
).
What this means is, for example, if you want to Add
2 to all the X coors but you do so additively, the first X will increase by 2, the second by 4, the third by 6, and so on.
Applying each of these operations can be reversed through the Undo button.
GCodeUtils.py
is where the G-Code parsing takes place:
Click to expand
from typing import Union
from pygcode import GCodeLinearMove
from eltypes import gcode_line, operator
from operators import add, sub, replace_op
def _apply_op_to_coor(
line: gcode_line, coor: str, op: operator, val: float, additive: bool,
only_for_val: Union[float, None]
) -> gcode_line:
found = False
gcodes = line.block.gcodes
for gcode in gcodes:
if type(gcode) is GCodeLinearMove:
current_coor = getattr(gcode, coor)
if current_coor is not None:
if only_for_val is not None:
if current_coor == only_for_val:
setattr(gcode, coor, op(current_coor, val))
if additive:
found = True
else:
setattr(gcode, coor, op(current_coor, val))
if additive:
found = True
return line, found
def inc_coor(
line: gcode_line, coor: str, val: float, additive: bool,
only_for_val: Union[float, None]
) -> gcode_line:
return _apply_op_to_coor(line, coor, add, val, additive, only_for_val)
def dec_coor(
line: gcode_line, coor: str, val: float, additive: bool,
only_for_val: Union[float, None]
) -> gcode_line:
return _apply_op_to_coor(line, coor, sub, val, additive, only_for_val)
def replace_coor(
line: gcode_line, coor: str, val: float, additive: bool,
only_for_val: Union[float, None]
) -> gcode_line:
return _apply_op_to_coor(
line, coor, replace_op, val, additive, only_for_val
)
tomllib
is used for loading the TOML file.
eltypes.py
is for creating custom types for better type hints, as well as grouping all types in a single source file:
Click to expand
from types import FunctionType
from pygcode import Line
from config_model import Config
config = dict
config_model = Config
gcode_line = Line
lines = list[gcode_line]
str_lines = list[str]
operator = FunctionType
Respectively, paths.py
holds all (assets & config) paths:
Click to expand
from pathlib import Path
from pyprojroot import here
_PATHS = {
"assets": "assets/",
"assets-delete": "assets/delete.png",
"assets-minus": "assets/minus.png",
"assets-multiply": "assets/multiply.png",
"assets-plus": "assets/plus.png",
"assets-replace": "assets/replace.png",
"assets-undo": "assets/undo.png",
"config": "src/config.toml"
}
def get_path(name: str, relative: bool) -> Path:
return here(_PATHS[name]) if not relative else _PATHS[name]
pathlib
is used for sane path handling across OSes.
pyprojroot
is used to locate the project root to better handle absolute paths.
It's akin to rprojroot
or here
.
Note: absolute paths can't be used to build the application, since we want it to be distributable and able to work regardless of the directory it's located in; therefore relative paths are used instead.
However, absolute paths with pyprojroot
can help a lot during development - refer to relative_paths = True
in main.py
.
Lastly, operators.py
holds a custom operator used in GCodeUtils.py
:
from operator import add, sub
add = add
sub = sub
def replace_op(a, b):
return b
highlighter.py
is an efficient implementation of a G-Code lexer/syntax highlighter using regular expressions:
Click to expand
from re import compile
from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QFont, QColor, QColorConstants
class Highlighter(QSyntaxHighlighter):
_KEYWORDS = [
"EQ", "NE", "LT", "GT", "LE", "GE", "AND", "OR", "XOR", "WHILE", "WH",
"END", "IF", "THEN", "ELSE", "ENDIF"
]
_OPERATORS = [
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "FIX", "FUP", "LN",
"ROUND", "SQRT", "FIX", "ABS", "MOD"
]
# Hack to be able to define a custom initializer.
# By default you can only implement the highlightBlock virtual function
# without messing up the way it connects to the text parent behind the scenes.
def __init__(self, parent=None):
QSyntaxHighlighter.__init__(self, parent)
self._initialize_formats()
self._initialize_rules()
def _initialize_formats(self):
all_formats = (
# name, color, bold, italic
("normal", None, False, False),
("keyword", QColorConstants.Blue, True, False),
("operator", QColorConstants.DarkMagenta, False, False),
("comment", QColorConstants.LightGray, False, False),
("gcode", QColorConstants.DarkBlue, True, False),
("mcode", QColorConstants.DarkBlue, True, False),
("coordinate", QColorConstants.Blue, True, False),
("string", QColorConstants.Green, False, False)
)
self._formats = {}
for name, color, bold, italic in all_formats:
format_ = QTextCharFormat()
if color:
format_.setForeground(QColor(color))
if bold:
format_.setFontWeight(QFont.Weight.Bold)
if italic:
format_.setFontItalic(True)
self._formats[name] = format_
def _initialize_rules(self):
r = []
def _a(a, b):
r.append((compile(a), b))
_a(
"|".join([r"\b%s\b" % keyword for keyword in self._KEYWORDS]),
"keyword"
)
_a(
"|".join([r"\b%s\b" % operator for operator in self._OPERATORS]),
"operator"
)
_a(r"(\\+|\\*|\\/|\\*\\*)", "operator")
_a(r"(\\(.+\\))", "comment")
_a(r";.*\n", "comment")
_a(r"[G](1)?5[4-9](.1)?\\s?(P[0-9]{1,3})?", "gcode")
_a(r"[G]1[1-2][0-9]", "gcode")
_a(r"[G]15\\s?(H[0-9]{1,2})?", "gcode")
_a(r"[G][0-9]{1,3}(\\.[0-9])?", "gcode")
_a(r"[M][0-9]{1,3}", "mcode")
_a(r"([X])\\s?(\\-?\\d*\\.?\\d+\\.?|\\-?\\.?(?=[#\\[]))", "coordinate")
_a(r"([Y])\\s?(\\-?\\d*\\.?\\d+\\.?|\\-?\\.?(?=[#\\[]))", "coordinate")
_a(r"([Z])\\s?(\\-?\\d*\\.?\\d+\\.?|\\-?\\.?(?=[#\\[]))", "coordinate")
_a(r"([\\%])", "string")
self._rules = tuple(r)
def highlightBlock(self, text: str) -> None:
text_length = len(text)
self.setFormat(0, text_length, self._formats["normal"])
for regex, format_ in self._rules:
for m in regex.finditer(text):
i, length = m.start(), m.end() - m.start()
self.setFormat(i, length, self._formats[format_])
config_model.py
holds the pydantic
BaseModel
representation of the application configuration:
from pydantic import BaseModel
class Config(BaseModel):
icon: dict[str, dict[str, int]]
window: dict[str, dict[str, int]]
coordinate: dict[str, dict[str, int]]
Finally, config.toml
, the (editable) configuration file:
Click to expand
[icon]
[icon.dimension]
width = 25
height = 25
[window]
[window.dimension]
width = 600
height = 700
[coordinate]
[coordinate.threshold]
min = 0
max = 300
Project and dependency management happens through poetry
:
Click to expand
[tool.poetry]
name = "loc-spotting-utils"
version = "0.1.0"
description = "Python utilties to assist in DIY microarray spotting using a 3D printer"
authors = ["Orestis Ousoultzoglou <[email protected]>"]
license = "MIT"
readme = "README.md"
packages = [{include = "loc_spotting_utils"}]
[tool.poetry.dependencies]
python = "~3.11"
pyside6 = "^6.4.0.1"
pyinstaller = { version = "^5.6.2", optional = true }
pygcode = "^0.2.1"
pyprojroot = "^0.2.0"
yapf = { version = "^0.32.0", optional = true }
toml = { version = "^0.10.2", optional = true }
pydantic = "^1.10.2"
pytest = { version = "^7.2.0", optional = true }
[tool.poetry.extras]
build = ["pyinstaller"]
format = ["yapf", "toml"]
test = ["pytest"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
pyside6
:PySide6
is the official Python module from the Qt for Python project, which provides access to the complete Qt 6.0+ framework. Used for the GUIpygcode
: Currently in development,pygcode
is a low-level GCode interpreter for python. Used for G-Code parsingpyprojroot
: Find relative paths from a project root directory. Used for making my life better during developmentpydantic
: Data validation and settings management using Python type hints. Used for validating the configuration TOML
Some clusters of optional dependencies have also been added. These aren't required for the application to run. These clusters are:
build
:pyinstaller
: PyInstaller bundles a Python application and all its dependencies into a single package. Used for bundling the application into a single folder with an executableformat
:test
:pytest
: Thepytest
framework makes it easy to write small tests, yet scales to support complex functional testing for applications and libraries. Can be used to set up unit testing some time in the future
add-files-to-spec
is a clever hack to add the data files to be bundled to thepyinstaller
spec
generated files throughsed
.editorconfig
is cool