prusaslicer profiles refactor and tests
This commit is contained in:
parent
131c676a38
commit
4bfc351189
13 changed files with 41248 additions and 694 deletions
|
|
@ -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.
|
||||||
- Stores configuration as a dictionary (`conf_dict`).
|
- Scans directories for `.ini` files and tracks changes using file metadata.
|
||||||
- Handles inheritance logic via `generate_inherited_confs`.
|
- Categorizes profiles into `printer`, `print`, and `filament`.
|
||||||
- Supports compatibility checks via `evaluate_compatibility`.
|
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
|
||||||
- **`generate_conf` Function**: Recursively merges inherited profiles into a single configuration dictionary.
|
- `generate_conf_writer`: Creates a `ConfigWriter` by merging a printer, print, and one or more filament profiles.
|
||||||
|
|
||||||
### 2. `profile_cache.py`
|
### 2. `Profile` (`models.py`)
|
||||||
- **`LocalCache` Class**: Manages a collection of profiles loaded from disk.
|
- Represents a single PrusaSlicer profile (Printer, Filament, or Print).
|
||||||
- Scans directories for `.ini` files and tracks changes using file metadata.
|
- Stores configuration as a dictionary (`conf_dict`).
|
||||||
- Categorizes profiles into `printers`, `print`, and `filament`.
|
- Handles inheritance logic via `generate_inherited_confs`.
|
||||||
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
|
- Supports compatibility checks via `evaluate_compatibility`.
|
||||||
- `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`
|
### 3. `ConfigWriter` (`models.py`)
|
||||||
- Implements a recursive descent parser for PrusaSlicer's expression language (used in `compatible_printers_condition` and similar fields).
|
- Helper to write merged configurations to a file (e.g., for passing to a slicer or embedding in a 3MF).
|
||||||
- **AST Nodes**: `ExprNode`, `LiteralNode`, `VarNode`, `IndexNode`, `UnaryOpNode`, `BinaryOpNode`.
|
- Includes checksum generation for configuration integrity.
|
||||||
- **`Parser` Class**: Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
|
|
||||||
|
|
||||||
### 4. `prusaslicer_fields.py`
|
### 4. `Parser` (`parser.py`)
|
||||||
|
- Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
|
||||||
|
- Supports variables, indexing, logical operators, and regex matching.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
|
||||||
4
infra/prusaslicer_profiles/__init__.py
Normal file
4
infra/prusaslicer_profiles/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .cache import LocalCache
|
||||||
|
from .models import Profile, ConfigWriter
|
||||||
|
|
||||||
|
__all__ = ['LocalCache', 'Profile', 'ConfigWriter']
|
||||||
165
infra/prusaslicer_profiles/cache.py
Normal file
165
infra/prusaslicer_profiles/cache.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -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}")
|
|
||||||
37
infra/prusaslicer_profiles/fields.py
Normal file
37
infra/prusaslicer_profiles/fields.py
Normal 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)
|
||||||
|
}
|
||||||
76
infra/prusaslicer_profiles/models.py
Normal file
76
infra/prusaslicer_profiles/models.py
Normal 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
|
||||||
289
infra/prusaslicer_profiles/parser.py
Normal file
289
infra/prusaslicer_profiles/parser.py
Normal 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}")
|
||||||
|
|
@ -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])))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
75
infra/prusaslicer_profiles/utils.py
Normal file
75
infra/prusaslicer_profiles/utils.py
Normal 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
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
61
tests/test_profiles.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue