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

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 

10 

11type Gcmd = Any 

12"""klipper/klippy/gcode.py GCodeCommand""" 

13 

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.""" 

18 

19type ConfigWrapper = Any 

20"""klipper/klippy/configfile.py ConfigWrapper""" 

21 

22type PrinterConfig = Any 

23"""klipper/klippy/configfile.py PrinterConfig""" 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class KlipperUtilsError(Exception): 

29 """Generic error for Klipper utility functions.""" 

30 

31class KlipperUtils: 

32 """Collection of Klipper utility functions.""" 

33 

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. 

37 

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 

54 

55 gcode.register_command(cmd=cmd_name, func=gcode_command, desc=help_text) 

56 

57 @staticmethod 

58 def call_command(gcmd: Gcmd, fun: Command, syntax_prefix: str = "") -> None: # noqa: C901 complexity = 9 

59 """Handle Klipper GCODE command automatically. 

60 

61 Parameters are checked for required/optional/unknown parameters and converted to the correct type. 

62 

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 

71 

72 params = {'gcmd': gcmd_params.pop('GCMD')} 

73 missing = [] 

74 invalid = [] 

75 syntax_parts = [] 

76 

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) 

83 

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()) 

94 

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}") 

102 

103 fun(**params) 

104 

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. 

108 

109 :param class_entry: Instance to create with key-value dict. 

110 :param string: Comma separated list of a key-value pair:: 

111 

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) 

121 

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. 

125 

126 Klipper config file example:: 

127 

128 config-name=<key 1a>=<value 1a>, <key 2a>=<value 2a>, ... 

129 <key 1b>=<value 1b>, <key 2a>=<value 2b>, ... 

130 ... 

131 

132 :param class_entry: Instance to create with key-value dict. 

133 :param entries: Config as a list of strings, 

134 Example:: 

135 

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 

144 

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) 

157 

158 @staticmethod 

159 def _convert_param(value: Any, annotation: type) -> Any: 

160 """Convert a parameter to the target type. 

161 

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 

170 

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) 

182 

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. 

186 

187 Klipper config file example:: 

188 

189 config-name=<key_1>=<value_1>, <key_2>=<value_2>, ... 

190 

191 Class represents the config file as instance variables: 

192 

193 .. code-block:: python 

194 

195 @dataclass 

196 class KlipperConfImpl(KlipperConf): 

197 key_1: float 

198 key_2: float 

199 

200 """ 

201 

202 @staticmethod 

203 @abstractmethod 

204 def get_title() -> list[str]: 

205 """Get the list of config variables. 

206 

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'. 

209 

210 :return: List of key names. 

211 """ 

212 

213 def to_string(self) -> str: 

214 """Comma separated list of all config variables :py:meth:`get_title` as <key>=<value> pairs. 

215 

216 I.e.:: 

217 

218 <key_1>=<value_1>, <key_2>=<value_2> 

219 

220 :return: String of all config variables. 

221 """ 

222 return ", ".join(f"{k}={v}" for k, v in self.get_data().items()) 

223 

224 def get_data(self) -> dict[str, float]: 

225 """Get the dictionary of all config variables. 

226 

227 :return: Dict of config variables. 

228 """ 

229 return {k: self.__dict__[k] for k in self.get_title()} 

230 

231 @override 

232 def __str__(self) -> str: 

233 """Get the string representation of the config data. 

234 

235 :return: String representation of the config data. 

236 """ 

237 return self.to_string()