Coverage for src / klipper_utils / klipper_utils.py: 86%
108 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 21:45 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 21:45 +0000
1"""Generic Klipper utility functions and classes."""
2import inspect
3import itertools
4import logging
5import types
6from abc import ABC, abstractmethod
7from collections.abc import Callable
8from dataclasses import dataclass
9from typing import Any, get_args, get_origin, override
11type Gcmd = Any
12"""klipper/klippy/gcode.py GCodeCommand"""
14type Command = Callable[..., None]
15# First parameter is always Gcmd. How to specify this? Following is not working for mypy:
16# Command = Callable[Concatenate[Gcmd, ...], None]
17"""Callable function to handle G-code command."""
19type ConfigWrapper = Any
20"""klipper/klippy/configfile.py ConfigWrapper"""
22type PrinterConfig = Any
23"""klipper/klippy/configfile.py PrinterConfig"""
25logger = logging.getLogger(__name__)
28class KlipperUtilsError(Exception):
29 """Generic error for Klipper utility functions."""
31class KlipperUtils:
32 """Collection of Klipper utility functions."""
34 @staticmethod
35 def register_command(gcode: Gcmd, function: Command, cmd_name: str, help_text: str, fail_on_error: bool) -> None:
36 """Register a command with Klipper G-code parser.
38 :param gcode: G-code object to register the command with.
39 :param function: Function to handle the command. Signature should be function(gcmd, parameters...).
40 :param cmd_name: Name of the command to register.
41 :param help_text: Help text for the command shown in HELP G-code.
42 :param fail_on_error: True: raise an error on command failure. False: just log the error.
43 """
44 def gcode_command(gcmd: Gcmd) -> None:
45 try:
46 KlipperUtils.call_command(gcmd=gcmd, fun=function, syntax_prefix=cmd_name)
47 # pylint: disable=broad-except
48 except Exception as err:
49 gcmd.respond_info(f"{cmd_name} error: {type} {err}")
50 if fail_on_error:
51 # Convert to a more specific error type if needed
52 # gcmd.error(f"{err}") # Klipper's CommandError with gcmd.error()
53 raise err # re-raise the error as is
55 gcode.register_command(cmd=cmd_name, func=gcode_command, desc=help_text)
57 @staticmethod
58 def call_command(gcmd: Gcmd, fun: Command, syntax_prefix: str = "") -> None: # noqa: C901 complexity = 9
59 """Handle Klipper GCODE command automatically.
61 Parameters are checked for required/optional/unknown parameters and converted to the correct type.
63 :param gcmd: G-code object.
64 :param fun: Command function to be called.
65 :param syntax_prefix: Prefix shown on syntax error, e.g. the command name.
66 :raises gcmd.error: If the parameters are invalid or missing, or if there are unknown parameters.
67 """
68 fun_params = inspect.signature(fun).parameters
69 gcmd_params = dict(gcmd.get_command_parameters())
70 gcmd_params['GCMD'] = gcmd # Always required
72 params = {'gcmd': gcmd_params.pop('GCMD')}
73 missing = []
74 invalid = []
75 syntax_parts = []
77 for name, sig in itertools.islice(fun_params.items(), 1, None): # Skip the first parameter 'gcmd'
78 # Build syntax help
79 part = f"{name.upper()}=({name.lower()})"
80 if sig.default is not inspect.Parameter.empty:
81 part = f"[{part}]" # optional parameter
82 syntax_parts.append(part)
84 value = gcmd_params.pop(name.upper(), None)
85 if value:
86 # type cast the value to correct type
87 try:
88 params[name] = KlipperUtils._convert_param(value, sig.annotation)
89 except (TypeError, ValueError) as _err:
90 logger.info(f"Parameter '{name}={value}' cannot be converted to '{sig.annotation}': {_err}")
91 invalid.append(f"{value} ({sig.annotation})")
92 elif sig.default is inspect.Parameter.empty:
93 missing.append(name.upper())
95 syntax = f"{syntax_prefix} {' '.join(syntax_parts) if syntax_parts else '(no parameters)'}"
96 if invalid:
97 raise gcmd.error(f"Parameters '{', '.join(invalid)}' have invalid value. Syntax is {syntax}")
98 if missing:
99 raise gcmd.error(f"Parameters '{', '.join(missing)}' are required. Syntax is {syntax}")
100 if gcmd_params:
101 raise gcmd.error(f"Unknown parameters '{', '.join(gcmd_params.keys())}'. Syntax is {syntax}")
103 fun(**params)
105 @staticmethod
106 def klipper_from_config_string[T: KlipperConfigDict](class_entry: type[T], string: str) -> T:
107 """Convert comma separated key-values pairs '<key>=<value>,...' to an instance created with matching kwargs.
109 :param class_entry: Instance to create with key-value dict.
110 :param string: Comma separated list of a key-value pair::
112 <key 1>=<value 1>, <key 2>=<value 2>...
113 :return: Instance of [T] created with dict of key-value pairs.
114 """
115 dict_entry: dict[str, str] = {
116 k.strip(): v.strip()
117 for item in string.split(',')
118 for k, v in [item.split('=', 1)] # Split only on the first '=', allows '=' in value
119 }
120 return class_entry(**dict_entry)
122 @staticmethod
123 def klipper_map_from_config[T: KlipperConfigDict](class_entry: type[T], entries: list[str]) -> list[T]:
124 r"""Convert a list of comma separated key-values pairs ['<key>=<value>,...', ...] to an instance created with matching kwargs.
126 Klipper config file example::
128 config-name=<key 1a>=<value 1a>, <key 2a>=<value 2a>, ...
129 <key 1b>=<value 1b>, <key 2a>=<value 2b>, ...
130 ...
132 :param class_entry: Instance to create with key-value dict.
133 :param entries: Config as a list of strings,
134 Example::
136 config.getlists("config-name", seps='\n', default=[])
137 :return: List of objects with type [T]
138 """
139 data = []
140 for entry in entries:
141 if entry:
142 data.append(KlipperUtils.klipper_from_config_string(class_entry, entry))
143 return data
145 @staticmethod
146 def _convert_to_type(v: Any, t: type) -> Any:
147 if t is bool:
148 if isinstance(v, str):
149 v_lower = v.lower()
150 if v_lower in ('true', '1', 'yes', 'on'):
151 return True
152 if v_lower in ('false', '0', 'no', 'off'):
153 return False
154 raise ValueError(f"Cannot convert string '{v}' to bool")
155 return bool(v)
156 return t(v)
158 @staticmethod
159 def _convert_param(value: Any, annotation: type) -> Any:
160 """Convert a parameter to the target type.
162 :param value: Value to convert.
163 :param annotation: Target type annotation, e.g. int, float, str, or Union[int, None].
164 :return: Converted value.
165 :raises TypeError: If the value cannot be converted to the target type.
166 """
167 # Check if type hint exists and is convertable
168 if annotation is Any or annotation is inspect.Parameter.empty:
169 return value
171 if get_origin(annotation) is types.UnionType:
172 union_types = get_args(annotation)
173 # Iterate through each type in the union and pick the first valid conversion
174 for u_t in union_types:
175 try:
176 # Attempt conversion to the current type
177 return KlipperUtils._convert_to_type(value, u_t)
178 except (TypeError, ValueError):
179 continue # Try the next type
180 raise TypeError(f"Cannot convert {value} to any of {union_types}")
181 return KlipperUtils._convert_to_type(value, annotation)
183@dataclass(kw_only=True, frozen=True)
184class KlipperConfigDict(ABC):
185 """Klipper dictionary type config file of comma separated key-values pairs and helper functions.
187 Klipper config file example::
189 config-name=<key_1>=<value_1>, <key_2>=<value_2>, ...
191 Class represents the config file as instance variables:
193 .. code-block:: python
195 @dataclass
196 class KlipperConfImpl(KlipperConf):
197 key_1: float
198 key_2: float
200 """
202 @staticmethod
203 @abstractmethod
204 def get_title() -> list[str]:
205 """Get the list of config variables.
207 This defines the which instance variables are the config variables, their stored order and naturally the name of the values.
208 E.g., returning ['low', 'high'] means the class has to have members 'low' and 'high'.
210 :return: List of key names.
211 """
213 def to_string(self) -> str:
214 """Comma separated list of all config variables :py:meth:`get_title` as <key>=<value> pairs.
216 I.e.::
218 <key_1>=<value_1>, <key_2>=<value_2>
220 :return: String of all config variables.
221 """
222 return ", ".join(f"{k}={v}" for k, v in self.get_data().items())
224 def get_data(self) -> dict[str, float]:
225 """Get the dictionary of all config variables.
227 :return: Dict of config variables.
228 """
229 return {k: self.__dict__[k] for k in self.get_title()}
231 @override
232 def __str__(self) -> str:
233 """Get the string representation of the config data.
235 :return: String representation of the config data.
236 """
237 return self.to_string()