prusaslicer profiles refactor and tests

This commit is contained in:
Nicolas Predella 2026-05-15 14:49:45 +03:00
parent 131c676a38
commit 4bfc351189
13 changed files with 41248 additions and 694 deletions

View file

@ -2,36 +2,41 @@
This module handles the parsing, management, and evaluation of PrusaSlicer configuration profiles (`.ini` files). It supports profile inheritance, compatibility evaluation using PrusaSlicer's expression language, and generating merged configurations for slicing. This module handles the parsing, management, and evaluation of PrusaSlicer configuration profiles (`.ini` files). It supports profile inheritance, compatibility evaluation using PrusaSlicer's expression language, and generating merged configurations for slicing.
## Project Structure
- **`__init__.py`**: Exports the primary API (`LocalCache`, `Profile`, `ConfigWriter`).
- **`cache.py`**: Contains the `LocalCache` class for managing profile loading and metadata.
- **`models.py`**: Defines core data models like `Profile` (as a dataclass) and `ConfigWriter`.
- **`parser.py`**: Implements a recursive descent parser for PrusaSlicer's expression language.
- **`fields.py`**: Robustly loads and searches PrusaSlicer setting metadata (JSON/CSV).
- **`utils.py`**: Contains canonical implementations of INI parsing and configuration merging (`generate_conf`).
## Core Components ## Core Components
### 1. `profiles.py` ### 1. `LocalCache` (`cache.py`)
- **`Profile` Class**: Represents a single PrusaSlicer profile (Printer, Filament, or Print). - Manages a collection of profiles loaded from disk.
- Scans directories for `.ini` files and tracks changes using file metadata.
- Categorizes profiles into `printer`, `print`, and `filament`.
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
- `generate_conf_writer`: Creates a `ConfigWriter` by merging a printer, print, and one or more filament profiles.
### 2. `Profile` (`models.py`)
- Represents a single PrusaSlicer profile (Printer, Filament, or Print).
- Stores configuration as a dictionary (`conf_dict`). - Stores configuration as a dictionary (`conf_dict`).
- Handles inheritance logic via `generate_inherited_confs`. - Handles inheritance logic via `generate_inherited_confs`.
- Supports compatibility checks via `evaluate_compatibility`. - Supports compatibility checks via `evaluate_compatibility`.
- **`generate_conf` Function**: Recursively merges inherited profiles into a single configuration dictionary.
### 2. `profile_cache.py` ### 3. `ConfigWriter` (`models.py`)
- **`LocalCache` Class**: Manages a collection of profiles loaded from disk. - Helper to write merged configurations to a file (e.g., for passing to a slicer or embedding in a 3MF).
- Scans directories for `.ini` files and tracks changes using file metadata. - Includes checksum generation for configuration integrity.
- Categorizes profiles into `printers`, `print`, and `filament`.
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
- `generate_conf_writer`: Creates a `ConfigWriter` by merging a printer, print, and one or more filament profiles (multi-extruder support).
- **`ConfigWriter` Class**: Helper to write the merged configuration to a file (e.g., for passing to a slicer or embedding in a 3MF).
- **Utility Functions**: `ini_to_dict` and `ini_content_to_dict` use `ConfigParser` to parse PrusaSlicer's INI format.
### 3. `expression_parser.py` ### 4. `Parser` (`parser.py`)
- Implements a recursive descent parser for PrusaSlicer's expression language (used in `compatible_printers_condition` and similar fields). - Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
- **AST Nodes**: `ExprNode`, `LiteralNode`, `VarNode`, `IndexNode`, `UnaryOpNode`, `BinaryOpNode`. - Supports variables, indexing, logical operators, and regex matching.
- **`Parser` Class**: Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
### 4. `prusaslicer_fields.py` ### 5. `fields.py`
- Loads metadata about PrusaSlicer configuration fields from `prusaslicer_fields.json` and `prusaslicer_modifier_fields.csv`. - Loads metadata about PrusaSlicer configuration fields from `prusaslicer_fields.json` and `prusaslicer_modifier_fields.csv`.
- Provides `search_in_db` and `search_in_mod_db` for looking up field definitions, types, and labels. - Uses robust path resolution relative to the module's directory.
## Data Files
- `prusaslicer_fields.json`: Exhaustive database of PrusaSlicer settings, including types, default values, and labels.
- `prusaslicer_modifier_fields.csv`: List of fields that are valid as modifiers.
## Typical Workflow ## Typical Workflow
1. Initialize `LocalCache`. 1. Initialize `LocalCache`.

View file

@ -0,0 +1,4 @@
from .cache import LocalCache
from .models import Profile, ConfigWriter
__all__ = ['LocalCache', 'Profile', 'ConfigWriter']

View file

@ -0,0 +1,165 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from .models import Profile, ConfigWriter
from .utils import ini_to_dict
class LocalCache:
def __init__(self):
self.profiles: dict[str, Profile] = {}
self.files_metadata: dict[str, float] = {}
@property
def display_profiles(self) -> dict[str, Profile]:
"""Profiles that are not hidden (don't contain '*' in their ID)."""
return {k: p for k, p in self.profiles.items() if '*' not in p.id}
@property
def printer_profiles(self) -> dict[str, Profile]:
return {k: p for k, p in self.display_profiles.items() if p.category == 'printer'}
@property
def print_profiles(self) -> dict[str, Profile]:
return {k: p for k, p in self.display_profiles.items() if p.category == 'print'}
@property
def filament_profiles(self) -> dict[str, Profile]:
return {k: p for k, p in self.display_profiles.items() if p.category == 'filament'}
@property
def vendors(self) -> list[str]:
return sorted({p.vendor for p in self.filament_profiles.values() if p.vendor})
def load(self, dirs: list[Path | str]) -> tuple[set[str], set[str], set[str]]:
"""
Loads profiles from the specified directories.
Returns sets of (changed, added, deleted) file paths.
"""
old_metadata = self.files_metadata.copy()
new_metadata = self._fetch_files_metadata(dirs)
changed = {k for k in old_metadata.keys() & new_metadata.keys() if old_metadata[k] != new_metadata[k]}
added = new_metadata.keys() - old_metadata.keys()
deleted = old_metadata.keys() - new_metadata.keys()
if not (changed | added | deleted):
return set(), set(), set()
# Handle deletions
for deleted_file in deleted:
keys_to_remove = [k for k, v in self.profiles.items() if str(v.path) == deleted_file]
for k in keys_to_remove:
self.profiles.pop(k, None)
# Handle additions and changes
for file_path in (changed | added):
self._process_ini_to_cache(file_path)
self.files_metadata = new_metadata
# Recalculate inheritance for all profiles
for profile in self.display_profiles.values():
profile.generate_inherited_confs(self.profiles)
return changed, added, deleted
def evaluate_compatibility(self, enabled_printers: set[str], enabled_vendors: set[str]):
"""Evaluates compatibility expressions for all active profiles."""
for k, printer in self.printer_profiles.items():
if k not in enabled_printers:
continue
# Combine vendors with empty string for default/generic profiles
active_vendors = enabled_vendors | {''}
active_filaments = {k: p for k, p in self.filament_profiles.items() if p.vendor in active_vendors}
compats = {
k: p.compatibility_expression
for k, p in (active_filaments | self.print_profiles).items()
}
printer.evaluate_compatibility(compats)
def generate_conf_writer(
self,
printer_id: str,
filament_ids: list[str],
print_id: str,
overrides: dict[str, dict[str, Any]]
) -> ConfigWriter:
"""Merges selected profiles and overrides into a ConfigWriter."""
from .fields import search_db
conf: dict[str, Any] = {}
# Add printer and print profile
conf.update(self.profiles[printer_id].all_conf_dict)
conf.update(self.profiles[print_id].all_conf_dict)
# Merge filament profiles (handling multi-extruder setups)
filament_confs = [self.profiles[fid].all_conf_dict for fid in filament_ids]
all_keys = set().union(*filament_confs)
all_keys.discard('cooling_slowdown_logic')
for key in all_keys:
key_props = search_db.get(key)
if not key_props:
print(f"Warning: Key '{key}' unrecognized in fields database.")
continue
default_val = key_props.get('default', '')
key_type = key_props['type']
# Select separator based on field type
list_types = {
'coPercents', 'coFloats', 'coFloatsOrPercents',
'coInts', 'coIntsNullable', 'coBools', 'coPoints'
}
separator = ',' if key_type in list_types else ';'
values = [d.get(key, default_val) for d in filament_confs]
conf[key] = separator.join(map(str, values))
# Apply overrides
for k, o in overrides.items():
conf[k] = o['value']
# Add profile names for reference
conf.update({
'printer_settings_id': printer_id.split(":")[1],
'filament_settings_id': ";".join(fid.split(":")[1] for fid in filament_ids),
'print_settings_id': print_id.split(":")[1],
})
return ConfigWriter(conf)
def _fetch_files_metadata(self, dirs: list[Path | str]) -> dict[str, float]:
metadata = {}
for d in dirs:
path = Path(os.path.expanduser(d)).resolve()
if not path.is_dir():
continue
for root, _, files in os.walk(path, followlinks=True):
for f in files:
if f.endswith('.ini'):
file_path = Path(root) / f
try:
metadata[str(file_path)] = file_path.stat().st_mtime
except OSError:
continue
return metadata
def _process_ini_to_cache(self, path: str):
has_header, sections = ini_to_dict(path)
for key, conf in sections.items():
if ":" in key:
category = key.split(':')[0]
self.profiles[key] = Profile(
key=key,
category=category,
path=Path(path),
has_header=has_header,
conf_dict=conf
)

View file

@ -1,335 +0,0 @@
from __future__ import annotations
import operator
from typing import TYPE_CHECKING, TypeAlias
if TYPE_CHECKING:
Token: TypeAlias = tuple[str, str]
import re
# === AST Node Definitions ===
_ops = {
"==": (str, operator.eq),
"!=": (str, operator.ne),
"<": (float, operator.lt),
">": (float, operator.gt),
"<=": (float, operator.le),
">=": (float, operator.ge),
}
class ExprNode:
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
raise NotImplementedError("Must implement eval in subclass")
class LiteralNode(ExprNode):
def __init__(self, value: str | re.Pattern[str]) -> None:
self.value: str | re.Pattern[str] = value
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
return self.value
class VarNode(ExprNode):
def __init__(self, name: str) -> None:
self.name: str = name
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
return context.get(self.name, '0')
class IndexNode(ExprNode):
def __init__(self, name: str, index: int) -> None:
self.name: str = name
self.index: int = index
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
val = context.get(self.name, '0')
val = val.split(',')
if self.index > len(val) -1: return '0'
return val[self.index]
class UnaryOpNode(ExprNode):
def __init__(self, op: str, child: ExprNode) -> None:
self.op = op
self.child = child
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
cval = self.child.eval(context)
if isinstance(cval, re.Pattern):
raise TypeError("Unary Operator cannot evaluate type re.Pattern")
if self.op == "!":
return not float(cval)
raise RuntimeError(f"Unknown unary operator {self.op}")
class BinaryOpNode(ExprNode):
def __init__(self, left: ExprNode, op: str, right: ExprNode) -> None:
self.left = left
self.op = op
self.right = right
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
if self.op in ["and", "&&"]:
if not self.left.eval(context): return False
return self.right.eval(context)
if self.op == "or":
if self.left.eval(context): return True
return self.right.eval(context)
# For everything else, we evaluate both sides
lv: str | float | re.Pattern[str] | bool = self.left.eval(context)
rv: str | float | re.Pattern[str] | bool = self.right.eval(context)
caster, func = _ops.get(self.op, (None, None))
if caster and func:
if isinstance(lv, re.Pattern) or isinstance(rv, re.Pattern):
raise RuntimeError(f"Operands of {self.op} must not be regex patterns")
return func(caster(lv), caster(rv))
if self.op == "=~":
if not isinstance(rv, re.Pattern):
raise TypeError("Right operand of =~ must be a regex pattern")
return rv.search(str(lv)) is not None
if self.op == "!~":
if not isinstance(rv, re.Pattern):
raise TypeError("Right operand of !~ must be a regex pattern")
return rv.search(str(lv)) is None
raise RuntimeError(f"Unknown binary operator {self.op}")
# === Parser & Tokenizer ===
class Parser:
def __init__(self, text: str):
self.text = text
self.tokens: list[Token] = self._tokenize(text)
self.i = 0 # index into tokens
def _tokenize(self, text: str) -> list[Token]:
tokens: list[Token] = []
i = 0
length: int = len(text)
while i < length:
c = text[i]
# Skip whitespace
if c.isspace():
i += 1
continue
# Parentheses / Brackets
if c == "(":
tokens.append(("LPAREN", c)); i += 1; continue
if c == ")":
tokens.append(("RPAREN", c)); i += 1; continue
if c == "[":
tokens.append(("LBRACK", c)); i += 1; continue
if c == "]":
tokens.append(("RBRACK", c)); i += 1; continue
# Multi‐char operators: check longest first
# Order matters: check 3‐character operators? We only have 2‐char: ==, !=, >=, <=, =~, !~, and the single‐char '!'
if text.startswith("==", i):
tokens.append(("OP", "==")); i += 2; continue
if text.startswith("!=", i):
tokens.append(("OP", "!=")); i += 2; continue
if text.startswith(">=", i):
tokens.append(("OP", ">=")); i += 2; continue
if text.startswith("<=", i):
tokens.append(("OP", "<=")); i += 2; continue
if text.startswith("=~", i):
tokens.append(("OP", "=~")); i += 2; continue
if text.startswith("!~", i):
tokens.append(("OP", "!~")); i += 2; continue
if text.startswith("&&", i):
tokens.append(("OP", "and")); i += 2; continue
if text.startswith("||", i):
tokens.append(("OP", "or")); i += 2; continue
# Single‐char '>' or '<'
if c == ">":
tokens.append(("OP", ">")); i += 1; continue
if c == "<":
tokens.append(("OP", "<")); i += 1; continue
# Single‐char '!'
if c == "!":
tokens.append(("OP", "!")); i += 1; continue
# Regex literal: starts with '/'
if c == "/":
# Read until the next unescaped '/'
j = i + 1
while j < length:
if text[j] == "/" and text[j-1] != "\\":
break
j += 1
if j >= length:
raise SyntaxError("Unterminated regex literal")
pattern_body = text[i+1:j]
tokens.append(("REGEX", pattern_body))
i = j + 1
continue
# Quoted string literal: " … " or ' … '
if c in ("'", '"'):
quote_char = c
j = i + 1
while j < length:
if text[j] == quote_char and text[j-1] != "\\":
break
j += 1
if j >= length:
raise SyntaxError("Unterminated string literal")
str_body = text[i+1: j]
# We do not interpret escape sequences here; we store raw content
tokens.append(("STRING", str_body))
i = j + 1
continue
# Number: [0-9]+(\.[0-9]+)?
if c.isdigit():
j = i
while j < length and (text[j].isdigit() or text[j] == "."):
j += 1
num_text = text[i:j]
tokens.append(("NUMBER", num_text))
i = j
continue
# Identifier or keyword (and/or)
if c.isalpha() or c == "_":
j = i
while j < length and (text[j].isalnum() or text[j] == "_"):
j += 1
ident = text[i:j]
if ident == "and":
tokens.append(("OP", "and"))
elif ident == "or":
tokens.append(("OP", "or"))
else:
tokens.append(("IDENT", ident))
i = j
continue
# If we get here, it's an unexpected character
raise SyntaxError(f"Unexpected character: {c}")
return tokens
def _peek(self) -> Token | None:
if self.i < len(self.tokens):
return self.tokens[self.i]
return None
def _advance(self) -> Token:
if self.i >= len(self.tokens):
raise SyntaxError("Unexpected end of input")
tok = self.tokens[self.i]
self.i += 1
return tok
def _expect(self, ttype: str, value: str | None = None) -> Token:
tok = self._peek()
if tok is None:
raise SyntaxError(f"Expected {ttype} but found end of input")
if tok[0] != ttype or (value is not None and tok[1] != value):
raise SyntaxError(f"Expected {ttype} {value!r} but found {tok}")
return self._advance()
def parse(self) -> ExprNode:
node = self._parse_or()
if self._peek() is not None:
raise SyntaxError(f"Unexpected token after end: {self._peek()}")
return node
def _parse_or(self) -> ExprNode:
left = self._parse_and()
while True:
tok = self._peek()
if tok is not None and tok[0] == "OP" and tok[1] == "or":
self._advance()
right = self._parse_and()
left = BinaryOpNode(left, "or", right)
else:
break
return left
def _parse_and(self) -> ExprNode:
left = self._parse_not()
while True:
tok = self._peek()
if tok is not None and tok[0] == "OP" and tok[1] == "and":
self._advance()
right = self._parse_not()
left = BinaryOpNode(left, "and", right)
else:
break
return left
def _parse_not(self) -> ExprNode:
tok = self._peek()
# If we see a lone '!' (not followed by '=' or '~'), it is unary NOT
if tok is not None and tok[0] == "OP" and tok[1] == "!":
# Make sure it's not part of '!=' or '!~' (those would have been tokenized as OP("!=") or OP("!~"))
self._advance()
child = self._parse_not()
return UnaryOpNode("!", child)
else:
return self._parse_comp()
def _parse_comp(self) -> ExprNode:
left = self._parse_atom()
tok = self._peek()
if tok is not None and tok[0] == "OP" and tok[1] in ("==", "!=", "<", ">", "<=", ">=", "=~", "!~"):
op = tok[1]
self._advance()
right = self._parse_atom()
return BinaryOpNode(left, op, right)
return left
def _parse_atom(self) -> ExprNode:
tok = self._peek()
if tok is None:
raise SyntaxError("Unexpected end of input in atom")
ttype, val = tok
if ttype in ["STRING", "NUMBER"]:
self._advance()
return LiteralNode(val)
if ttype == "REGEX":
self._advance()
try:
pat = re.compile(val)
except re.error as e:
raise SyntaxError(f"Invalid regex /{val}/: {e}")
return LiteralNode(pat)
if ttype == "IDENT":
# Could be a variable or keyword—but "and"/"or" handled earlier
name = val
self._advance()
# Check for indexing: IDENT '[' NUMBER ']'
nxt = self._peek()
if nxt is not None and nxt[0] == "LBRACK":
self._advance() # consume '['
idx_tok = self._expect("NUMBER")
idx = int(idx_tok[1])
self._expect("RBRACK")
return IndexNode(name, idx)
else:
return VarNode(name)
if ttype == "LPAREN":
self._advance()
node = self._parse_or()
self._expect("RPAREN")
return node
raise SyntaxError(f"Unexpected token in atom: {tok}")

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
import os
from functools import lru_cache
from ..json_handling import dict_from_json
from ..csv_handling import parse_csv_to_dict
# Robust path resolution relative to this file
BASE_DIR = os.path.dirname(__file__)
search_db_path: str = os.path.join(BASE_DIR, 'prusaslicer_fields.json')
search_db: dict[str, dict[str, Any]] = dict_from_json(search_db_path)
search_db_mod_path: str = os.path.join(BASE_DIR, 'prusaslicer_modifier_fields.csv')
search_db_mod: dict[str, list[str]] = parse_csv_to_dict(search_db_mod_path)
@lru_cache(maxsize=128)
def search_in_db(term: str) -> dict[str, dict[str, Any]]:
"""Searches the PrusaSlicer fields database for a given term."""
words = term.lower().split()
return {
k: v for k, v in search_db.items()
if all(word in k + " " + v.get('label', '') for word in words)
}
@lru_cache(maxsize=128)
def search_in_mod_db(term: str) -> dict[str, dict[str, Any]]:
"""Searches the PrusaSlicer modifier fields database for a given term."""
words = term.lower().split()
filtered_db = {k: v for k, v in search_db.items() if k in search_db_mod}
return {
k: v for k, v in filtered_db.items()
if all(word in k + " " + v.get('label', '') for word in words)
}

View file

@ -0,0 +1,76 @@
from __future__ import annotations
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from .parser import ExprNode
@dataclass
class Profile:
key: str
category: str
path: Path
has_header: bool
conf_dict: dict[str, Any]
id: str = field(init=False)
vendor: str = ""
all_conf_dict: dict[str, Any] = field(default_factory=dict)
compatible_profiles: list[str] = field(default_factory=list)
compatibility_expression: ExprNode | None = None
def __post_init__(self):
self.id = self.key.split(':')[1] if len(self.key.split(':')) > 1 else self.key
def evaluate_compatibility(self, compats: dict[str, ExprNode | None]):
self.compatible_profiles = []
if self.category.startswith('printer:'):
return
for key, compat in compats.items():
if not compat:
self.compatible_profiles.append(key)
continue
if compat.eval(self.all_conf_dict):
self.compatible_profiles.append(key)
print(f'Evaled {self.id}')
def generate_inherited_confs(self, all_profiles: dict[str, Profile]):
from .utils import generate_conf
from .parser import Parser
self.all_conf_dict = generate_conf(all_profiles, self.key)
if self.category == 'printer':
nozzle_diameters = self.all_conf_dict.get('nozzle_diameter', '0.4')
self.all_conf_dict['num_extruders'] = str(len(nozzle_diameters.split(',')))
self.vendor = self.all_conf_dict.get('filament_vendor', '')
if exp := self.all_conf_dict.get('compatible_printers_condition'):
try:
self.compatibility_expression = Parser(exp).parse()
except Exception as e:
print(f'Expression parsing failed for {self.id}: {exp} - {e}')
class ConfigWriter:
def __init__(self, conf: dict[str, str | list[str]]) -> None:
self.config_dict: dict[str, str | list[str]] = conf
self.temp_dir = tempfile.gettempdir()
def write_ini_3mf(self, config_local_path: Path):
with open(config_local_path, 'w') as file:
for key, val in dict(sorted(self.config_dict.items())).items():
file.write(f"; {key} = {val}\n")
def get(self, key: str, default: Any = None) -> str | list[str]:
return self.config_dict.get(key, default)
@property
def checksum(self) -> int:
import json, zlib
data = json.dumps(self.config_dict, sort_keys=True).encode("utf-8")
return zlib.crc32(data) & 0xFFFFFFFF

View file

@ -0,0 +1,289 @@
from __future__ import annotations
import operator
import re
from typing import TYPE_CHECKING, TypeAlias, Any
if TYPE_CHECKING:
Token: TypeAlias = tuple[str, str]
# === AST Node Definitions ===
_ops = {
"==": (str, operator.eq),
"!=": (str, operator.ne),
"<": (float, operator.lt),
">": (float, operator.gt),
"<=": (float, operator.le),
">=": (float, operator.ge),
}
class ExprNode:
"""Base class for all expression nodes in the AST."""
def eval(self, context: dict[str, Any]) -> Any:
raise NotImplementedError("Must implement eval in subclass")
class LiteralNode(ExprNode):
"""Represents a literal value (string, number, or regex pattern)."""
def __init__(self, value: Any) -> None:
self.value = value
def eval(self, context: dict[str, Any]) -> Any:
return self.value
class VarNode(ExprNode):
"""Represents a variable look-up in the configuration context."""
def __init__(self, name: str) -> None:
self.name = name
def eval(self, context: dict[str, Any]) -> Any:
return context.get(self.name, '0')
class IndexNode(ExprNode):
"""Represents an indexed variable look-up (e.g., nozzle_diameter[0])."""
def __init__(self, name: str, index: int) -> None:
self.name = name
self.index = index
def eval(self, context: dict[str, Any]) -> Any:
val = str(context.get(self.name, '0'))
parts = val.split(',')
if self.index >= len(parts):
return '0'
return parts[self.index].strip()
class UnaryOpNode(ExprNode):
"""Represents a unary operator (e.g., !)."""
def __init__(self, op: str, child: ExprNode) -> None:
self.op = op
self.child = child
def eval(self, context: dict[str, Any]) -> Any:
cval = self.child.eval(context)
if isinstance(cval, re.Pattern):
raise TypeError("Unary operator cannot be applied to a regex pattern.")
if self.op == "!":
try:
return not float(cval)
except (ValueError, TypeError):
return not bool(cval)
raise RuntimeError(f"Unknown unary operator: {self.op}")
class BinaryOpNode(ExprNode):
"""Represents a binary operator (e.g., ==, &&, =~)."""
def __init__(self, left: ExprNode, op: str, right: ExprNode) -> None:
self.left = left
self.op = op
self.right = right
def eval(self, context: dict[str, Any]) -> Any:
# Short-circuiting for logical operators
if self.op in ("and", "&&"):
lv = self.left.eval(context)
if not lv or lv == '0':
return False
return self.right.eval(context)
if self.op in ("or", "||"):
lv = self.left.eval(context)
if lv and lv != '0':
return True
return self.right.eval(context)
lv = self.left.eval(context)
rv = self.right.eval(context)
# Standard comparisons
if self.op in _ops:
caster, func = _ops[self.op]
if isinstance(lv, re.Pattern) or isinstance(rv, re.Pattern):
raise RuntimeError(f"Operands of {self.op} cannot be regex patterns.")
try:
return func(caster(lv), caster(rv))
except (ValueError, TypeError):
# Fallback to string comparison if cast fails
return func(str(lv), str(rv))
# Regex matching
if self.op == "=~":
if not isinstance(rv, re.Pattern):
raise TypeError("Right operand of =~ must be a regex pattern.")
return rv.search(str(lv)) is not None
if self.op == "!~":
if not isinstance(rv, re.Pattern):
raise TypeError("Right operand of !~ must be a regex pattern.")
return rv.search(str(lv)) is None
raise RuntimeError(f"Unknown binary operator: {self.op}")
class Parser:
"""Recursive descent parser for PrusaSlicer expressions."""
def __init__(self, text: str):
self.text = text
self.tokens = self._tokenize(text)
self.pos = 0
def _tokenize(self, text: str) -> list[Token]:
tokens: list[Token] = []
i = 0
length = len(text)
while i < length:
c = text[i]
if c.isspace():
i += 1
continue
# Fixed tokens
if c == "(": tokens.append(("LPAREN", "(")); i += 1; continue
if c == ")": tokens.append(("RPAREN", ")")); i += 1; continue
if c == "[": tokens.append(("LBRACK", "[")); i += 1; continue
if c == "]": tokens.append(("RBRACK", "]")); i += 1; continue
# Multi-character operators
if text.startswith("==", i): tokens.append(("OP", "==")); i += 2; continue
if text.startswith("!=", i): tokens.append(("OP", "!=")); i += 2; continue
if text.startswith(">=", i): tokens.append(("OP", ">=")); i += 2; continue
if text.startswith("<=", i): tokens.append(("OP", "<=")); i += 2; continue
if text.startswith("=~", i): tokens.append(("OP", "=~")); i += 2; continue
if text.startswith("!~", i): tokens.append(("OP", "!~")); i += 2; continue
if text.startswith("&&", i): tokens.append(("OP", "and")); i += 2; continue
if text.startswith("||", i): tokens.append(("OP", "or")); i += 2; continue
if c == ">": tokens.append(("OP", ">")); i += 1; continue
if c == "<": tokens.append(("OP", "<")); i += 1; continue
if c == "!": tokens.append(("OP", "!")); i += 1; continue
# Regex literal
if c == "/":
j = i + 1
while j < length:
if text[j] == "/" and text[j-1] != "\\":
break
j += 1
if j >= length:
raise SyntaxError("Unterminated regex literal.")
tokens.append(("REGEX", text[i+1:j]))
i = j + 1
continue
# String literal
if c in ("'", '"'):
quote = c
j = i + 1
while j < length:
if text[j] == quote and text[j-1] != "\\":
break
j += 1
if j >= length:
raise SyntaxError("Unterminated string literal.")
tokens.append(("STRING", text[i+1:j]))
i = j + 1
continue
# Number
if c.isdigit():
j = i
while j < length and (text[j].isdigit() or text[j] == "."):
j += 1
tokens.append(("NUMBER", text[i:j]))
i = j
continue
# Identifier or Logical keyword
if c.isalpha() or c == "_":
j = i
while j < length and (text[j].isalnum() or text[j] == "_"):
j += 1
ident = text[i:j]
if ident == "and": tokens.append(("OP", "and"))
elif ident == "or": tokens.append(("OP", "or"))
else: tokens.append(("IDENT", ident))
i = j
continue
raise SyntaxError(f"Unexpected character in expression: {c}")
return tokens
def _peek(self) -> Token | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def _advance(self) -> Token:
tok = self._peek()
if not tok:
raise SyntaxError("Unexpected end of input.")
self.pos += 1
return tok
def _expect(self, ttype: str, value: str | None = None) -> Token:
tok = self._peek()
if not tok or tok[0] != ttype or (value and tok[1] != value):
raise SyntaxError(f"Expected {ttype} {value or ''}, got {tok}")
return self._advance()
def parse(self) -> ExprNode:
node = self._parse_or()
if self._peek():
raise SyntaxError(f"Trailing tokens after expression: {self._peek()}")
return node
def _parse_or(self) -> ExprNode:
left = self._parse_and()
while (tok := self._peek()) and tok[0] == "OP" and tok[1] == "or":
self._advance()
left = BinaryOpNode(left, "or", self._parse_and())
return left
def _parse_and(self) -> ExprNode:
left = self._parse_not()
while (tok := self._peek()) and tok[0] == "OP" and tok[1] == "and":
self._advance()
left = BinaryOpNode(left, "and", self._parse_not())
return left
def _parse_not(self) -> ExprNode:
if (tok := self._peek()) and tok[0] == "OP" and tok[1] == "!":
self._advance()
return UnaryOpNode("!", self._parse_not())
return self._parse_comp()
def _parse_comp(self) -> ExprNode:
left = self._parse_atom()
ops = ("==", "!=", "<", ">", "<=", ">=", "=~", "!~")
if (tok := self._peek()) and tok[0] == "OP" and tok[1] in ops:
op = tok[1]
self._advance()
left = BinaryOpNode(left, op, self._parse_atom())
return left
def _parse_atom(self) -> ExprNode:
tok = self._advance()
ttype, val = tok
if ttype in ("STRING", "NUMBER"):
return LiteralNode(val)
if ttype == "REGEX":
try:
return LiteralNode(re.compile(val))
except re.error as e:
raise SyntaxError(f"Invalid regex /{val}/: {e}")
if ttype == "IDENT":
if (nxt := self._peek()) and nxt[0] == "LBRACK":
self._advance()
idx = int(self._expect("NUMBER")[1])
self._expect("RBRACK")
return IndexNode(val, idx)
return VarNode(val)
if ttype == "LPAREN":
node = self._parse_or()
self._expect("RPAREN")
return node
raise SyntaxError(f"Unexpected token in atom: {tok}")

View file

@ -1,251 +0,0 @@
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Any
from .profiles import Profile
class LocalCache:
profiles: dict[str, Profile] = {}
files_metadata: dict[str, Any] = {}
@property
def display_profiles(self) -> dict[str, Profile]:
return {k: profile for k, profile in self.profiles.items() if '*' not in profile.id}
@property
def printers_profiles(self) -> dict[str, Profile]:
return {k: profile for k, profile in self.display_profiles.items() if profile.category == 'printer'}
@property
def print_profiles(self) -> dict[str, Profile]:
return {k: profile for k, profile in self.display_profiles.items() if profile.category == 'print'}
@property
def filament_profiles(self) -> dict[str, Profile]:
return {k: profile for k, profile in self.display_profiles.items() if profile.category == 'filament'}
def load(self, dirs: list[Path | str]) -> tuple[dict[str, tuple[Any, Any]], dict[str, Any], dict[str, Any]]:
old = self.files_metadata.copy()
self._fetch_files_metadata(dirs)
new = self.files_metadata
changed = {k: (old[k], new[k]) for k in old.keys() & new.keys() if old[k] != new[k]}
added = {k: new[k] for k in new.keys() - old.keys()}
deleted = {k: old[k] for k in old.keys() - new.keys()}
if len(changed | added | deleted) == 0: return {}, {}, {}
for deleted_file in deleted:
keys_to_remove = [key for key, val in self.profiles.items() if str(val.path) == deleted_file]
for key in keys_to_remove:
self.profiles.pop(key, None)
for file_path in (changed | added):
self._process_ini_to_cache_dict(file_path)
self.files_metadata = new
for k, profile in self.display_profiles.items():
profile.generate_inherited_confs(self.profiles)
return changed, added, deleted
@property
def vendors(self):
return sorted({ p.vendor for p in self.filament_profiles.values() })
def evaluate_compatibility(self, enabled_printers: set[str], enabled_vendors: set[str]):
for k, profile in self.printers_profiles.items():
if k not in enabled_printers: continue
# if profile.compatible_profiles: continue
enabled_vendors.add("")
enabled_filament_profiles = {k: p for k, p in self.filament_profiles.items() if p.vendor in enabled_vendors | {''}}
profile.evaluate_compatibility({k: pp.compatibility_expression for k, pp in (enabled_filament_profiles | self.print_profiles).items()})
def _fetch_files_metadata(self, dirs: list[Path | str]):
self.files_metadata = {}
# Iterate over all provided directories
for directory in dirs:
sanitized_path = self._sanitize_directory(directory)
if not sanitized_path:
continue
# Use os.walk with followlinks=True to ensure linked folders are processed
for root, _, files in os.walk(sanitized_path, followlinks=True):
for file in files:
if file.endswith('.ini'):
file_path = Path(root) / file
try:
last_modified = file_path.stat().st_mtime
self.files_metadata[str(file_path)] = last_modified
except OSError as e:
print(f"Error reading file {file_path}: {e}")
continue
def _sanitize_directory(self, dir_str: Path | str) -> Path | None:
if not dir_str:
return None
sanitized = Path(os.path.expanduser(dir_str)).resolve()
if not sanitized.is_dir():
print(f"Path is not a valid directory: {dir_str}")
return None
return sanitized
def _process_ini_to_cache_dict(self, path: str):
# Convert ConfigParser content into a dictionary
has_header, ini_dict = ini_to_dict(path)
# Flatten the dictionary for profiles and add to self.config_headers
for key, conf_dict in ini_dict.items():
if ":" in key:
self.profiles[key] = Profile(
key,
key.split(':')[0] if len(key.split(':')) > 1 else '',
Path(path),
has_header,
conf_dict
)
return
def generate_conf_writer(self, printer_profile: str, filament_profile: list[str], print_profile: str, overrides: dict[str, dict[str, str]]) -> 'ConfigWriter':
from .prusaslicer_fields import search_db
conf = {}
# add printer and print profile
conf.update(self.profiles[printer_profile].all_conf_dict)
conf.update(self.profiles[print_profile].all_conf_dict)
# add filament profiles per extruder
filament_merged_conf: dict[str, str] = {}
filament_confs: list[dict[str, str]] = [self.profiles[profile].all_conf_dict for profile in filament_profile]
common_keys: set[str] = set().union(*filament_confs)
common_keys.discard('cooling_slowdown_logic')
for key in common_keys:
key_props: dict[str, Any] | None = search_db.get(key, None)
if not key_props: print(f'Key {key} unrecognized! Check your configuration'); continue
default_value: str = key_props.get('default', '')
key_type: str = key_props['type']
separator = ',' if key_type in ['coPercents', 'coFloats', 'coFloatsOrPercents', 'coInts', 'coIntsNullable', 'coBools', 'coPoints'] else ';'
values: list[str] = [
d.get(key, default_value)
for d in filament_confs
]
joined: str = separator.join(values)
filament_merged_conf[key] = joined
conf.update(filament_merged_conf)
# overwrite with overrides
conf.update({k: o['value'] for k, o in overrides.items()})
# add profile names
conf.update({
'printer_settings_id': printer_profile.split(":")[1],
'filament_settings_id': ";".join(p.split(":")[1] for p in filament_profile),
'print_settings_id': print_profile.split(":")[1],
})
return ConfigWriter(conf)
class ConfigWriter:
def __init__(self, conf: dict[str, str | list[str]]) -> None:
self.config_dict: dict[str, str | list[str]] = conf
self.temp_dir = tempfile.gettempdir()
def write_ini_3mf(self, config_local_path: Path):
with open(config_local_path, 'w') as file:
for key, val in dict(sorted(self.config_dict.items())).items():
file.write(f"; {key} = {val}\n")
def get(self, key: str, default: Any = None) -> str | list[str]:
return self.config_dict[key]
@property
def checksum(self) -> int:
import json, zlib
data = json.dumps(self.config_dict, sort_keys=True).encode("utf-8")
return zlib.crc32(data) & 0xFFFFFFFF
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
if not (profile := profiles.get(id)): return {}
if not profile.conf_dict: return {}
conf_current = profiles[id].conf_dict # Copy to avoid modifying the original config
if conf_current.get('inherits', False):
curr_category = id.split(":")[0]
inherited_ids = [curr_category + ":" + inherit_id.strip() for inherit_id in conf_current['inherits'].split(';')] # Split on semicolon for multiple inheritance
merged_conf = {}
for inherit_id in inherited_ids:
if inherit_id in profiles:
inherited_conf = generate_conf(profiles, inherit_id) # Recursive call for each inherited config
merged_conf.update(inherited_conf) # Merge each inherited config
merged_conf.update(conf_current) # Update with current config values (overriding inherited)
conf_current = merged_conf
conf_current.pop('inherits', None)
conf_current.pop('renamed_from', None)
return conf_current
import re
from configparser import ConfigParser, MissingSectionHeaderError
def ini_to_dict(path: str) -> tuple[bool, dict[str, dict[str, str]]]:
# Read the file content from the path
with open(path, 'r') as file:
content = file.read()
config = ConfigParser(interpolation=None)
try:
# Attempt to parse the content
config.read_string(content)
has_header = True
except MissingSectionHeaderError:
# Determine category based on specific IDs in the content
if re.search(r'^filament_settings_id', content, re.MULTILINE):
cat = 'filament'
elif re.search(r'^print_settings_id', content, re.MULTILINE):
cat = 'print'
elif re.search(r'^printer_settings_id', content, re.MULTILINE):
cat = 'printer'
else:
raise ValueError(f"Unable to determine category for the INI file: {path}")
# Extract the filename without extension
name = os.path.splitext(os.path.basename(path))[0]
# Create a default section with the determined category and name
default_section = f"[{cat}:{name}]\n" + content
config.read_string(default_section)
has_header = False
# Convert ConfigParser content into a dictionary
return has_header, {
section: dict(sorted(config.items(section)))
for section in sorted(config.sections())
}
def ini_content_to_dict(path: str) -> dict[str, str]:
# Read the file content from the path
with open(path, 'r') as file:
content = file.read()
config = ConfigParser(interpolation=None)
default_section = f"[default:default]\n" + content
config.read_string(default_section)
return dict(sorted(config.items(config.sections()[0])))

View file

@ -1,55 +0,0 @@
from pathlib import Path
from typing import Any
from .expression_parser import Parser, ExprNode
class Profile():
def __init__(self, key: str, category: str, path: Path, has_header: bool, conf_dict: dict[str, Any]):
self.id: str = key.split(':')[1] if len(key.split(':')) > 1 else key
self.key: str = key
self.category: str = category
self.vendor: str = ''
self.path: Path = path
self.has_header: bool = has_header
self.conf_dict: dict[str, Any] = conf_dict
self.all_conf_dict: dict[str, Any] = {}
self.compatible_profiles: list[str] = []
self.compatibility_expression: ExprNode | None = None
def evaluate_compatibility(self, compats: dict[str, ExprNode | None]):
self.compatible_profiles = []
if self.category.startswith('printer:'): return
for key, compat in compats.items():
if not compat: self.compatible_profiles.append(key); continue
if compat.eval(self.all_conf_dict): self.compatible_profiles.append(key)
pass
print(f'Evaled {self.id}')
def generate_inherited_confs(self, all_confs_dict: dict[str, Any] = {}):
self.all_conf_dict = generate_conf(all_confs_dict, self.key)
if self.category == 'printer': self.all_conf_dict['num_extruders'] = str(len(self.all_conf_dict['nozzle_diameter'].split(',')))
self.vendor = self.all_conf_dict.get('filament_vendor', '')
if exp := self.all_conf_dict.get('compatible_printers_condition'):
try:
self.compatibility_expression = Parser(exp).parse()
except:
print(f'Expression parsing failed: {exp}')
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
if not (profile := profiles.get(id)): return {}
if not profile.conf_dict: return {}
conf_current = profiles[id].conf_dict # Copy to avoid modifying the original config
if conf_current.get('inherits', False):
curr_category = id.split(":")[0]
inherited_ids = [curr_category + ":" + inherit_id.strip() for inherit_id in conf_current['inherits'].split(';')] # Split on semicolon for multiple inheritance
merged_conf = {}
for inherit_id in inherited_ids:
if inherit_id in profiles:
inherited_conf = generate_conf(profiles, inherit_id) # Recursive call for each inherited config
merged_conf.update(inherited_conf) # Merge each inherited config
merged_conf.update(conf_current) # Update with current config values (overriding inherited)
conf_current = merged_conf
conf_current.pop('inherits', None)
conf_current.pop('renamed_from', None)
return conf_current

View file

@ -1,29 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
import os
from functools import lru_cache
from ..json_handling import dict_from_json
from ..csv_handling import parse_csv_to_dict
search_db_path: str = os.path.join('prusaslicer_fields.json')
search_db: dict[str, dict[str, Any]] = dict_from_json(search_db_path)
search_db_mod_path: str = os.path.join('prusaslicer_modifier_fields.csv')
search_db_mod: dict[str, list[str]] = parse_csv_to_dict(search_db_mod_path)
@lru_cache(maxsize=128)
def search_in_db(term) -> dict[str, dict[str, Any]]:
words: Any = term.lower().split()
return {k: v for k, v in search_db.items() if all([word in k+" "+v.get('label','') for word in words])}
@lru_cache(maxsize=128)
def search_in_mod_db(term) -> dict[str, dict[str, Any]]:
words = term.lower().split()
filtered_search_db = {k: v for k, v in search_db.items() if k in search_db_mod}
return {k: v for k, v in filtered_search_db.items() if all([word in k+" "+v.get('label','') for word in words])}
pass

View file

@ -0,0 +1,75 @@
from __future__ import annotations
import os
import re
from configparser import ConfigParser, MissingSectionHeaderError
from typing import Any
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
"""Recursively merges inherited profiles into a single configuration dictionary."""
if not (profile := profiles.get(id)):
return {}
if not profile.conf_dict:
return {}
conf_current = profiles[id].conf_dict.copy()
if conf_current.get('inherits', False):
curr_category = id.split(":")[0]
# Split on semicolon for multiple inheritance
inherited_ids = [
curr_category + ":" + inherit_id.strip()
for inherit_id in conf_current['inherits'].split(';')
]
merged_conf = {}
for inherit_id in inherited_ids:
if inherit_id in profiles:
inherited_conf = generate_conf(profiles, inherit_id)
merged_conf.update(inherited_conf)
merged_conf.update(conf_current)
conf_current = merged_conf
conf_current.pop('inherits', None)
conf_current.pop('renamed_from', None)
return conf_current
def ini_to_dict(path: str) -> tuple[bool, dict[str, dict[str, str]]]:
"""Parses a PrusaSlicer INI file into a dictionary of sections."""
with open(path, 'r') as file:
content = file.read()
config = ConfigParser(interpolation=None)
try:
config.read_string(content)
has_header = True
except MissingSectionHeaderError:
# Determine category based on specific IDs in the content
if re.search(r'^filament_settings_id', content, re.MULTILINE):
cat = 'filament'
elif re.search(r'^print_settings_id', content, re.MULTILINE):
cat = 'print'
elif re.search(r'^printer_settings_id', content, re.MULTILINE):
cat = 'printer'
else:
raise ValueError(f"Unable to determine category for the INI file: {path}")
name = os.path.splitext(os.path.basename(path))[0]
default_section = f"[{cat}:{name}]\n" + content
config.read_string(default_section)
has_header = False
return has_header, {
section: dict(sorted(config.items(section)))
for section in sorted(config.sections())
}
def ini_content_to_dict(path: str) -> dict[str, str]:
"""Parses a PrusaSlicer INI file without sections into a flat dictionary."""
with open(path, 'r') as file:
content = file.read()
config = ConfigParser(interpolation=None)
default_section = f"[default:default]\n" + content
config.read_string(default_section)
return dict(sorted(config.items(config.sections()[0])))

40512
tests/2.4.5.ini Normal file

File diff suppressed because one or more lines are too long

61
tests/test_profiles.py Normal file
View file

@ -0,0 +1,61 @@
import os
import sys
import unittest
from pathlib import Path
# Add the project root to sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT))
from infra.prusaslicer_profiles.cache import LocalCache
from infra.prusaslicer_profiles.fields import search_in_db
class TestPrusaSlicerProfiles(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cache = LocalCache()
cls.test_dir = PROJECT_ROOT / "tests"
cls.ini_file = cls.test_dir / "2.4.5.ini"
if not cls.ini_file.exists():
raise FileNotFoundError(f"Test baseline {cls.ini_file} not found.")
cls.cache.load([cls.test_dir])
def test_profile_counts(self):
"""Verify that a large number of profiles are loaded from the baseline INI."""
self.assertGreater(len(self.cache.profiles), 6000)
self.assertGreater(len(self.cache.printer_profiles), 200)
self.assertGreater(len(self.cache.print_profiles), 500)
self.assertGreater(len(self.cache.filament_profiles), 5000)
def test_inheritance_and_vendor(self):
"""Verify that inheritance is resolved and vendor information is extracted."""
# Find a known filament profile
filament_keys = [k for k in self.cache.display_profiles.keys() if k.startswith("filament:")]
self.assertGreater(len(filament_keys), 0)
profile = self.cache.profiles[filament_keys[0]]
self.assertTrue(len(profile.all_conf_dict) > 0)
self.assertTrue(len(profile.vendor) > 0 or profile.all_conf_dict.get('filament_vendor') is None)
def test_fields_database(self):
"""Verify that the fields database is accessible and searchable."""
res = search_in_db("bed shape")
self.assertIn("bed_shape", res)
self.assertEqual(res["bed_shape"].get("label"), "Bed shape")
def test_compatibility(self):
"""Verify that compatibility evaluation runs without errors."""
printer_keys = set(self.cache.printer_profiles.keys())
vendors = set(self.cache.vendors)
# This should not raise any exceptions
self.cache.evaluate_compatibility(printer_keys, vendors)
# Check if at least one printer has compatible profiles
has_compatible = any(p.compatible_profiles for p in self.cache.printer_profiles.values())
self.assertTrue(has_compatible, "No compatible profiles found for any printer.")
if __name__ == "__main__":
unittest.main()