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