from __future__ import annotations import ast import operator import re from typing import Any SCALE = {"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "cm": 10.0, "m": 1000.0, "meter": 1000.0, "meters": 1000.0, "in": 25.4, "inch": 25.4, "inches": 25.4, "ft": 304.8, "foot": 304.8, "feet": 304.8} _NUM_UNIT = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?:\*\s*)?([A-Za-z]+)\s*$") def _eval(node: ast.AST, names: dict[str, float]) -> float: if isinstance(node, ast.Expression): return _eval(node.body, names) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value) if isinstance(node, ast.Name) and node.id in names: return names[node.id] if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): value = _eval(node.operand, names); return value if isinstance(node.op, ast.UAdd) else -value if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): left, right = _eval(node.left, names), _eval(node.right, names) return {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}[type(node.op)](left, right) raise ValueError("expression is not a safe constant") def length_mm(expression: Any, names: dict[str, float] | None = None) -> float: if not isinstance(expression, str): raise ValueError("missing length expression") text = expression.strip().replace("millimeter", "mm").replace("inch", "in") match = _NUM_UNIT.match(text) if match: unit = match.group(2).lower() if unit not in SCALE: raise ValueError(f"unsupported unit {unit!r}") return float(match.group(1)) * SCALE[unit] env = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "inch": 25.4, "in": 25.4, **(names or {})} try: return _eval(ast.parse(text, mode="eval"), env) except Exception as exc: raise ValueError(f"unsupported length expression {expression!r}") from exc