Source code for gcode_reader.read

import re

from . import syntax
from . import preprocess

# Local regex compile cache for hot paths
_local_pattern_cache = {}
# Per-flavor type-converter cache: id(flavor) -> (type_map, default_type)
_converter_cache: dict = {}
# Per-word_map cache: id(word_map) -> (scalar_pairs, vector_pairs)
_word_map_parts_cache: dict = {}
# Per-flavor read spec: id(flavor) -> (comment_re, word_re, comment_sentinel)
_read_spec_cache: dict = {}


def _get_converters(flavor: dict):
    flavor_id = id(flavor)
    if flavor_id not in _converter_cache:
        type_map = flavor.get("data_type_map", {})
        default_type = type_map.get("default", str)
        # Build a case-extended map so the hot loop can skip .lower() on every word key.
        extended_type_map = {}
        for key, type_converter in type_map.items():
            if key != "default":
                extended_type_map[key] = type_converter
                extended_type_map[key.upper()] = type_converter
        _converter_cache[flavor_id] = (extended_type_map, default_type)
    return _converter_cache[flavor_id]


def _get_read_spec(flavor: dict):
    flavor_id = id(flavor)
    if flavor_id in _read_spec_cache:
        return _read_spec_cache[flavor_id]
    comment_re = _c(flavor.get("comment_re"))
    word_re = _c(flavor["word_re"])
    # Infer comment sentinel: first literal character of the comment pattern
    comment_sentinel = None
    if comment_re is not None:
        pattern_str = comment_re.pattern
        if pattern_str and pattern_str[0] not in r"([\^$":
            comment_sentinel = pattern_str[0]
    _read_spec_cache[flavor_id] = (comment_re, word_re, comment_sentinel)
    return _read_spec_cache[flavor_id]


def _c(pattern):
    if pattern is None:
        return None
    if isinstance(pattern, re.Pattern):
        return pattern
    if pattern not in _local_pattern_cache:
        _local_pattern_cache[pattern] = re.compile(pattern)
    return _local_pattern_cache[pattern]


[docs] def read_gcode_line(line: str, named_parameters: dict = {}, flavor: str = "default"): """Reads a G-code line into a clean dictionary of words and comments Args: line (str): Un-processed line (block) from a G-code program flavor (str, optional): Syntax definition for line. Defaults to "default". Returns: dict: Words and comments """ if isinstance( flavor, str ): # allows us to just continue if flavor is already a dict flavor = syntax.resolve_flavor(flavor) _raw_line = "%s" % line line = preprocess.line_insert_parameters(line, named_parameters, flavor) line = preprocess.line_insert_function_expressions(line, flavor) line = preprocess.line_insert_generic_expressions(line, flavor) comment_re, word_re, comment_sentinel = _get_read_spec(flavor) # Fast path: skip comment regex scan when sentinel char is absent if comment_sentinel is not None and comment_sentinel not in line: comments = [] no_comment_line = line elif comment_re is not None: comments = comment_re.findall(line) no_comment_line = comment_re.sub("", line) else: comments = [] no_comment_line = line all_words = word_re.findall(no_comment_line) if word_re is not None else [] type_map, default_type = _get_converters(flavor) # Fast path: build dict with type conversion in a single comprehension when there # are no duplicate keys (the common case). Detects duplicates via length comparison. processed_line = { key: type_map.get(key, default_type)(val) for key, val in all_words } if len(processed_line) < len(all_words): # Duplicates exist — re-build with list-aggregation and convert processed_line = {} for key, val in all_words: if key in processed_line: existing = processed_line[key] if isinstance(existing, list): existing.append(val) else: processed_line[key] = [existing, val] else: processed_line[key] = val for key, val in processed_line.items(): target_type = type_map.get(key, default_type) if isinstance(val, list): processed_line[key] = [target_type(v) for v in val] else: processed_line[key] = target_type(val) # Hendrick has multiple comment formats, so a tuple of a comment and empty string is returned for i in range(len(comments)): if type(comments[i]) != str: comments[i] = "".join(comments[i]) processed_line["comment"] = "".join(comments) processed_line["raw"] = _raw_line return processed_line
def _clean_word_value(key, val, flavor: str = "default"): """Function to convert the raw string value for a word to the syntax specified data type""" if isinstance( flavor, str ): # allows us to just continue if flavor is already a dict flavor = syntax.resolve_flavor(flavor) # Safely obtain the targeted type for conversion # Fall back on str as the default clean_key = key.lower() type_map = flavor.get("data_type_map", {}) default_type = type_map.get("default", str) target_type = type_map.get(clean_key, default_type) try: if isinstance(val, (list, tuple)): return [target_type(item) for item in val] else: return target_type(val) except: raise TypeError( f'Failed to convert Word ("{key}", "{val}") to required type {target_type.__name__}' )
[docs] def read_gcode_file(filepath: str, flavor: str = "default", encoding="utf-8"): """Reads a G-code file into a set of clean dictionaries of words Args: filepath (str): Path (absolute or relative) to a G-code program file flavor (str, optional): Syntax definition for line. Defaults to "default". encoding (str, optional): File encoding. Defaults to 'utf-8'. Returns: Generator[dict]: All lines as clean dictionary of words """ flavor = syntax.resolve_flavor(flavor) with open(filepath, "r", encoding=encoding) as f: gcode = f.readlines() named_parameters = {} # Cache patterns outside the hot loop comment_re_compiled = _c(flavor["comment_re"]) cmd_re_compiled = preprocess._get_compiled(flavor.get("command_re")) for line in gcode: # Don't look for parameters and modal groups in comments first_comment = ( comment_re_compiled.search(line) if comment_re_compiled is not None else None ) if first_comment is not None and first_comment.start() == 0: yield read_gcode_line(line, named_parameters, flavor) else: # Split off modal groups into individual lines modal_group = ( cmd_re_compiled.findall(line) if cmd_re_compiled is not None else [] ) line_is_modal_group = len(modal_group) > 1 if line_is_modal_group: for command in modal_group: # Just using dict(command) doesn't work for CEAD (no clue why) clean_value = _clean_word_value(command[0], command[1], flavor) comments = ( comment_re_compiled.findall(line) if comment_re_compiled is not None else [] ) yield { command[0]: clean_value, "comment": "".join(comments), "raw": line, } continue # Capture parameter declarations # NOTE: This might be too rigid for G-code styles that only declare parameters on unique lines line_declares_parameters, named_parameters = ( preprocess.line_declares_parameters(line, named_parameters, flavor) ) if line_declares_parameters: continue # Process the line yield read_gcode_line(line, named_parameters, flavor)
def _get_word_map_parts(word_map: dict): """Returns (scalars, vectors, scalar_word_keys_set, scalar_reverse_map). - scalars: list of (abstract_key, word_key) for non-vector entries (legacy API) - vectors: list of (abstract_key, [word_key, ...]) - scalar_word_keys_set: frozenset of word_keys for fast membership intersection - scalar_reverse_map: dict mapping word_key -> tuple of abstract_keys (a single word_key may map to multiple abstract concepts, e.g. MasterPrint maps F to both feed_rate and time_s). """ word_map_id = id(word_map) if word_map_id not in _word_map_parts_cache: scalars = [] vectors = [] scalar_reverse: dict = {} for key, word_key in word_map.items(): if word_key is None: continue if isinstance(word_key, list): vectors.append((key, word_key)) else: scalars.append((key, word_key)) scalar_reverse.setdefault(word_key, []).append(key) # Freeze the per-key abstract lists into tuples for cheap iteration. scalar_reverse = {k: tuple(v) for k, v in scalar_reverse.items()} scalar_set = frozenset(scalar_reverse) _word_map_parts_cache[word_map_id] = ( scalars, vectors, scalar_set, scalar_reverse, ) return _word_map_parts_cache[word_map_id]
[docs] def apply_word_abstractions(word_map: dict, words: dict) -> dict: """Translates a dictionary of G-code words into abstract command concepts. Uses a word map to reverse-map concrete G-code words (e.g., 'X', 'F') into their abstract representations (e.g., 'location', 'feed_rate'). Modifies `words` in-place by adding the abstract keys, then returns it. Args: word_map (dict): A dictionary mapping abstract concepts to their G-code word representations. See `syntax.flavors` for examples. words (dict): Parsed G-code words from a single line. Returns: dict: The same `words` dict, with abstract keys added. """ _, vectors, scalar_set, scalar_reverse = _get_word_map_parts(word_map) # Fast path: only scan keys that are actually present in `words` and map to a scalar. present = scalar_set & words.keys() for word_key in present: val = words[word_key] for abstract_key in scalar_reverse[word_key]: words[abstract_key] = val for key, word_keys in vectors: # Build the value list only when at least one component key is present. for k in word_keys: if k in words: words[key] = [words.get(k2) for k2 in word_keys] break return words