Coverage for pycommons / types.py: 100%

71 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 03:04 +0000

1"""Some basic type handling routines.""" 

2from typing import Any, Iterable 

3 

4 

5def type_name(tpe: type | None) -> str: 

6 """ 

7 Convert a type to a string which represents its name. 

8 

9 :param tpe: the type 

10 :returns: the string 

11 

12 >>> type_name(None) 

13 'None' 

14 >>> type_name(type(None)) 

15 'None' 

16 >>> type_name(int) 

17 'int' 

18 >>> from pycommons.io.path import file_path, Path 

19 >>> type_name(Path) 

20 'pycommons.io.path.Path' 

21 >>> from typing import Callable 

22 >>> type_name(Callable) 

23 'typing.Callable' 

24 >>> from typing import Callable as Ca 

25 >>> type_name(Ca) 

26 'typing.Callable' 

27 >>> from typing import Callable as Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 

28 >>> type_name(Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa) 

29 'typing.Callable' 

30 >>> import typing as ttttttttttttttttttttttttttttttttttttttttttt 

31 >>> type_name(ttttttttttttttttttttttttttttttttttttttttttt.Callable) 

32 'typing.Callable' 

33 """ 

34 if tpe is None: 

35 return "None" 

36 c1: str = str(tpe) 

37 if c1.startswith("<class '"): 

38 c1 = c1[8:-2] 

39 if c1 == "NoneType": 

40 return "None" 

41 if hasattr(tpe, "__qualname__"): 

42 c2: str = tpe.__qualname__ 

43 if hasattr(tpe, "__module__"): 

44 module = tpe.__module__ 

45 if (module is not None) and (module != "builtins"): 

46 c2 = f"{module}.{c2}" 

47 if len(c2) >= len(c1): 

48 return c2 

49 return c1 # will probably never happen 

50 

51 

52def type_name_of(obj: Any) -> str: 

53 """ 

54 Get the fully-qualified class name of an object. 

55 

56 :param obj: the object 

57 :returns: the fully-qualified class name of the object 

58 

59 >>> from pycommons.io.path import Path, file_path 

60 >>> type_name_of(Path) 

61 'type' 

62 >>> type_name_of(file_path(__file__)) 

63 'pycommons.io.path.Path' 

64 >>> type_name_of(None) 

65 'None' 

66 >>> type_name_of(int) 

67 'type' 

68 >>> type_name_of(print) 

69 'builtin_function_or_method' 

70 >>> from typing import Callable 

71 >>> type_name_of(Callable) 

72 'typing._CallableType' 

73 >>> from math import sin 

74 >>> type_name_of(sin) 

75 'builtin_function_or_method' 

76 >>> import pycommons.io as iox 

77 >>> type_name_of(iox) 

78 'module' 

79 """ 

80 if obj is None: 

81 return "None" 

82 if isinstance(obj, type): 

83 return "type" 

84 return type_name(type(obj)) 

85 

86 

87def type_error(obj: Any, name: str, 

88 expected: type | Iterable[type] | None = None, 

89 call: bool = False) -> ValueError | TypeError: 

90 """ 

91 Create an error to raise if a type did not fit. 

92 

93 This eror contains information about the object name, the expected type, 

94 the actual type, and, in some cases, the actual value of the object. This 

95 should help tracing down what went wrong. 

96 

97 We *sometimes* include the actual value of the object. This happens if 

98 the object is an `int`, `float`, or `bool`. If the object is a `str`, then 

99 we include at most the first 32 characters. If the ojbect is a `list`, 

100 `tuple`, `set`, `dict`, or `frozenset`, then we include its length. 

101 

102 In previous versions of this function, we always included the full 

103 representation of the object. However, this might lead to very lengthy 

104 output and could even cause an out-of-memory exception. So we now focus on 

105 the above classes only. 

106 

107 Since one might still try to cause some mischief by overriding the 

108 `__str__` or `__len__` methods of these objects, we force that the methods 

109 of the base classes are used, which looks a bit odd in the code but should 

110 at least somewhat help preventing issues. 

111 

112 :param obj: the object that is of the wrong type 

113 :param name: the name of the object 

114 :param expected: the expected types (or `None`) 

115 :param call: the object should have been callable? 

116 :returns: a :class:`TypeError` with a descriptive information 

117 

118 >>> type_error(1.3, "var", int) 

119 TypeError('var should be an instance of int but is float, namely 1.3.') 

120 >>> type_error("x", "z", (int, float)).args[0] 

121 "z should be an instance of any in {float, int} but is str, namely 'x'." 

122 >>> type_error("x", "z", (int, float, None)).args[0] 

123 "z should be an instance of any in {None, float, int} but is str, namely \ 

124'x'." 

125 >>> type_error("x", "z", (int, float, type(None))).args[0] 

126 "z should be an instance of any in {None, float, int} but is str, namely \ 

127'x'." 

128 >>> type_error("f", "q", call=True).args[0] 

129 "q should be a callable but is str, namely 'f'." 

130 >>> type_error("1", "2", bool, call=True).args[0] 

131 "2 should be an instance of bool or a callable but is str, namely '1'." 

132 >>> type_error(None, "x", str) 

133 TypeError('x should be an instance of str but is None.') 

134 >>> type_error("123456789012345678901234567890123456789", "var", int) 

135 TypeError("var should be an instance of int but is str, namely \ 

136'123456789012345678901234567890...'.") 

137 >>> type_error("12345678901234567890123456789012", "var", int) 

138 TypeError("var should be an instance of int but is str, namely \ 

139'12345678901234567890123456789012'.") 

140 >>> type_error("123456789012345678901234567890123", "var", int) 

141 TypeError("var should be an instance of int but is str, namely \ 

142'12345678901234567890123456789...'.") 

143 >>> type_error([1], "var", int) 

144 TypeError('var should be an instance of int but is list of length 1.') 

145 >>> type_error({2, 3}, "var", int) 

146 TypeError('var should be an instance of int but is set of length 2.') 

147 >>> type_error((1, 2, 3), "var", int) 

148 TypeError('var should be an instance of int but is tuple of length 3.') 

149 >>> type_error({}, "var", int) 

150 TypeError('var should be an instance of int but is dict of length 0.') 

151 >>> type_error(frozenset((23, )), "var", int) 

152 TypeError('var should be an instance of int but is frozenset of \ 

153length 1.') 

154 >>> type_error(1, "var", list) 

155 TypeError('var should be an instance of list but is int, namely 1.') 

156 >>> type_error(1.3, "var", list) 

157 TypeError('var should be an instance of list but is float, namely 1.3.') 

158 >>> type_error(True, "var", list) 

159 TypeError('var should be an instance of list but is bool, namely True.') 

160 >>> type_error(ValueError("x"), "var", list) 

161 TypeError('var should be an instance of list but is ValueError.') 

162 >>> type_error(None, "var", list) 

163 TypeError('var should be an instance of list but is None.') 

164 """ 

165 exp: str = "" 

166 if isinstance(expected, Iterable): 

167 exp = ", ".join(sorted(map(type_name, expected))) 

168 exp = f"an instance of any in {{{exp}}}" 

169 elif expected is not None: 

170 exp = f"an instance of {type_name(expected)}" 

171 if call: 

172 exp = f"{exp} or a callable" if exp else "a callable" 

173 

174 message: str 

175 if obj is None: 

176 message = "None" 

177 else: 

178 message = type_name_of(obj) 

179 if isinstance(obj, bool): 

180 message = f"{message}, namely {bool.__str__(obj)}" 

181 elif isinstance(obj, int): 

182 message = f"{message}, namely {int.__str__(obj)}" 

183 elif isinstance(obj, float): 

184 message = f"{message}, namely {float.__str__(obj)}" 

185 elif isinstance(obj, str): 

186 strlen: int = str.__len__(obj) 

187 if strlen > 32: # take care of strings that are too long 

188 obj = str.__getitem__(obj, slice(0, 30, 1)) + "..." 

189 message = f"{message}, namely {str.__str__(obj)!r}" 

190 elif isinstance(obj, list): 

191 message = f"{message} of length {list.__len__(obj)}" 

192 elif isinstance(obj, tuple): 

193 message = f"{message} of length {tuple.__len__(obj)}" 

194 elif isinstance(obj, set): 

195 message = f"{message} of length {set.__len__(obj)}" 

196 elif isinstance(obj, dict): 

197 message = f"{message} of length {dict.__len__(obj)}" 

198 elif isinstance(obj, frozenset): 

199 message = f"{message} of length {frozenset.__len__(obj)}" 

200 message = f"{name} should be {exp} but is {message}." 

201 

202 return TypeError(message) 

203 

204 

205def check_int_range(val: Any, name: str | None = None, 

206 min_value: int | float = 0, 

207 max_value: int | float = 1_000_000_000) -> int: 

208 """ 

209 Check whether a value `val` is an integer in a given range. 

210 

211 Via type annotation, this method actually accepts a value `val` of any 

212 type as input. However, if `val` is not an instance of `int`, it will 

213 throw an error. Also, if `val` is not in the prescribed range, it will 

214 throw an error, too. By default, the range is `0...1_000_000_000`. 

215 

216 I noticed that often, we think that only want to check a lower limit 

217 for `val`, e.g., that a number of threads or a population size should be 

218 `val > 0`. However, in such cases, there also always a reasonable upper 

219 limits. We never actually want an EA to have a population larger than, 

220 say, 1_000_000_000. That would make no sense. So indeed, whenever we have 

221 a lower limit for a parameter, we also should have an upper limit 

222 resulting from physical constraints. 1_000_000_000 is a reasonably sane 

223 upper limit in many situations. If we need smaller or larger limits, we 

224 can of course specify them. 

225 

226 Notice that there is one strange border case: In Python, `bool` is a 

227 subtype of `int`, where `True` has value `1` and `False` has value `0`. 

228 See <https://docs.python.org/3/library/functions.html#bool>. 

229 We therefore treat `bool` values indeed as instances of `int`. 

230 

231 :param val: the value to check 

232 :param name: the name of the value, or `None` 

233 :param min_value: the minimum permitted value 

234 :param max_value: the maximum permitted value 

235 :returns: `val` if everything is OK 

236 :raises TypeError: if `val` is not an `int` 

237 :raises ValueError: if `val` is an `int` but outside the prescribed range 

238 

239 >>> try: 

240 ... print(check_int_range(12, min_value=7, max_value=13)) 

241 ... except (ValueError, TypeError) as err: 

242 ... print(err) 

243 12 

244 

245 >>> try: 

246 ... print(check_int_range(123, min_value=7, max_value=13)) 

247 ... except (ValueError, TypeError) as err: 

248 ... print(err) 

249 ... print(err.__class__) 

250 Value=123 is invalid, must be in 7..13. 

251 <class 'ValueError'> 

252 

253 >>> try: 

254 ... print(check_int_range(5.0, name="ThisIsFloat")) 

255 ... except (ValueError, TypeError) as err: 

256 ... print(err) 

257 ... print(err.__class__) 

258 ThisIsFloat should be an instance of int but is float, namely 5.0. 

259 <class 'TypeError'> 

260 

261 The behavior in the border case of `bool` instances actually also being 

262 instances of `int`: 

263 

264 >>> check_int_range(True, "true", 0, 2) 

265 True 

266 

267 >>> check_int_range(False, "false", 0, 2) 

268 False 

269 

270 >>> try: 

271 ... print(check_int_range(True, min_value=7, max_value=13)) 

272 ... except (ValueError, TypeError) as err: 

273 ... print(err) 

274 ... print(err.__class__) 

275 Value=True is invalid, must be in 7..13. 

276 <class 'ValueError'> 

277 """ 

278 if not isinstance(val, int): 

279 raise type_error(val, "value" if name is None else name, int) 

280 if min_value <= val <= max_value: 

281 return val 

282 raise ValueError(f"{'Value' if name is None else name}={val!r} is " 

283 f"invalid, must be in {min_value}..{max_value}.") 

284 

285 

286def check_to_int_range(val: Any, name: str | None = None, 

287 min_value: int | float = 0, 

288 max_value: int | float = 1_000_000_000) -> int: 

289 """ 

290 Check whether a value `val` can be converted an integer in a given range. 

291 

292 :param val: the value to convert via `int(...)` and then to check 

293 :param name: the name of the value, or `None` 

294 :param min_value: the minimum permitted value 

295 :param max_value: the maximum permitted value 

296 :returns: `val` if everything is OK 

297 :raises TypeError: if `val` is `None` 

298 :raises ValueError: if `val` is not `None` but can either not be converted 

299 to an `int` or to an `int` outside the prescribed range 

300 

301 >>> try: 

302 ... print(check_to_int_range(12)) 

303 ... except (ValueError, TypeError) as err: 

304 ... print(err) 

305 12 

306 

307 >>> try: 

308 ... print(check_to_int_range(12.0)) 

309 ... except (ValueError, TypeError) as err: 

310 ... print(err) 

311 12 

312 

313 >>> try: 

314 ... print(check_to_int_range("12")) 

315 ... except (ValueError, TypeError) as err: 

316 ... print(err) 

317 12 

318 

319 >>> try: 

320 ... print(check_to_int_range("A")) 

321 ... except (ValueError, TypeError) as err: 

322 ... print(err) 

323 ... print(err.__class__) 

324 Cannot convert value='A' to int, let alone in range 0..1000000000. 

325 <class 'ValueError'> 

326 

327 >>> try: 

328 ... print(check_to_int_range(None)) 

329 ... except (ValueError, TypeError) as err: 

330 ... print(err) 

331 ... print(err.__class__) 

332 Cannot convert value=None to int, let alone in range 0..1000000000. 

333 <class 'TypeError'> 

334 """ 

335 try: 

336 conv = int(val) 

337 except (ValueError, TypeError) as errx: 

338 raise (ValueError if isinstance(errx, ValueError) else TypeError)( 

339 f"Cannot convert {'value' if name is None else name}={val!r} " 

340 f"to int, let alone in range {min_value}..{max_value}.") from errx 

341 return check_int_range(conv, name, min_value, max_value)