Skip to content

Repurposing a 3D printer for cell spotting to use in lab-on-a-chip prototyping

License

Notifications You must be signed in to change notification settings

AcubeSAT/spotting-utils

Repository files navigation

Description

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!).

Table of Contents

Click to expand

GUI Spotting Utilities

Description

A cross-platform GUI bundled as an executable to help quickly adjust G-Code files that contain spotting instructions.

Example screenshot

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 commenting M862.3 P "MK3S" ; printer model check
  • block code remaining '.1', solved by commenting M115 U3.10.1 ; tell printer latest fw version
  • word 'W' value invalid, solved by commenting G28 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.

File Structure

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

CI

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.

Assets

Icons:

Source

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:

  1. Add: Increase coordinate(s) by specified amount (plus button)
  2. Sub: Decrease coordinate(s) by specified amount (minus button)
  3. Replace: Set coordinate(s) to new value (replace button)
  4. Replicate: Replicated selected text X times (multiply button)
  5. 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

Dependencies

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 GUI
  • pygcode: Currently in development, pygcode is a low-level GCode interpreter for python. Used for G-Code parsing
  • pyprojroot: Find relative paths from a project root directory. Used for making my life better during development
  • pydantic: 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 executable
  • format:
    • yapf: A formatter for Python files. Used for, well, you guessed it. Also used in CI
    • toml: A Python library for parsing and creating TOML. Not used for parsing the config file. It's required from yapf
  • test: pytest: The pytest 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

Rest