1""" 2 pint.definitions 3 ~~~~~~~~~~~~~~~~ 4 5 Functions and classes related to unit definitions. 6 7 :copyright: 2016 by Pint Authors, see AUTHORS for more details. 8 :license: BSD, see LICENSE for more details. 9""" 10 11from __future__ import annotations 12 13from collections import namedtuple 14from typing import Callable, Iterable, Optional, Union 15 16from .converters import Converter, LogarithmicConverter, OffsetConverter, ScaleConverter 17from .errors import DefinitionSyntaxError 18from .util import ParserHelper, UnitsContainer, _is_dim 19 20 21class PreprocessedDefinition( 22 namedtuple("PreprocessedDefinition", "name symbol aliases value rhs_parts") 23): 24 """Splits a definition into the constitutive parts. 25 26 A definition is given as a string with equalities in a single line:: 27 28 ---------------> rhs 29 a = b = c = d = e 30 | | | -------> aliases (optional) 31 | | | 32 | | -----------> symbol (use "_" for no symbol) 33 | | 34 | ---------------> value 35 | 36 -------------------> name 37 38 Attributes 39 ---------- 40 name : str 41 value : str 42 symbol : str or None 43 aliases : tuple of str 44 rhs : tuple of str 45 """ 46 47 @classmethod 48 def from_string(cls, definition: str) -> PreprocessedDefinition: 49 name, definition = definition.split("=", 1) 50 name = name.strip() 51 52 rhs_parts = tuple(res.strip() for res in definition.split("=")) 53 54 value, aliases = rhs_parts[0], tuple([x for x in rhs_parts[1:] if x != ""]) 55 symbol, aliases = (aliases[0], aliases[1:]) if aliases else (None, aliases) 56 if symbol == "_": 57 symbol = None 58 aliases = tuple([x for x in aliases if x != "_"]) 59 60 return cls(name, symbol, aliases, value, rhs_parts) 61 62 63class _NotNumeric(Exception): 64 """Internal exception. Do not expose outside Pint""" 65 66 def __init__(self, value): 67 self.value = value 68 69 70def numeric_parse(s: str, non_int_type: type = float): 71 """Try parse a string into a number (without using eval). 72 73 Parameters 74 ---------- 75 s : str 76 non_int_type : type 77 78 Returns 79 ------- 80 Number 81 82 Raises 83 ------ 84 _NotNumeric 85 If the string cannot be parsed as a number. 86 """ 87 ph = ParserHelper.from_string(s, non_int_type) 88 89 if len(ph): 90 raise _NotNumeric(s) 91 92 return ph.scale 93 94 95class Definition: 96 """Base class for definitions. 97 98 Parameters 99 ---------- 100 name : str 101 Canonical name of the unit/prefix/etc. 102 symbol : str or None 103 A short name or symbol for the definition. 104 aliases : iterable of str 105 Other names for the unit/prefix/etc. 106 converter : callable or Converter or None 107 """ 108 109 def __init__( 110 self, 111 name: str, 112 symbol: Optional[str], 113 aliases: Iterable[str], 114 converter: Optional[Union[Callable, Converter]], 115 ): 116 117 if isinstance(converter, str): 118 raise TypeError( 119 "The converter parameter cannot be an instance of `str`. Use `from_string` method" 120 ) 121 122 self._name = name 123 self._symbol = symbol 124 self._aliases = tuple(aliases) 125 self._converter = converter 126 127 @property 128 def is_multiplicative(self) -> bool: 129 return self._converter.is_multiplicative 130 131 @property 132 def is_logarithmic(self) -> bool: 133 return self._converter.is_logarithmic 134 135 @classmethod 136 def from_string( 137 cls, definition: Union[str, PreprocessedDefinition], non_int_type: type = float 138 ) -> "Definition": 139 """Parse a definition. 140 141 Parameters 142 ---------- 143 definition : str or PreprocessedDefinition 144 non_int_type : type 145 146 Returns 147 ------- 148 Definition or subclass of Definition 149 """ 150 151 if isinstance(definition, str): 152 definition = PreprocessedDefinition.from_string(definition) 153 154 if definition.name.startswith("@alias "): 155 return AliasDefinition.from_string(definition, non_int_type) 156 elif definition.name.startswith("["): 157 return DimensionDefinition.from_string(definition, non_int_type) 158 elif definition.name.endswith("-"): 159 return PrefixDefinition.from_string(definition, non_int_type) 160 else: 161 return UnitDefinition.from_string(definition, non_int_type) 162 163 @property 164 def name(self) -> str: 165 return self._name 166 167 @property 168 def symbol(self) -> str: 169 return self._symbol or self._name 170 171 @property 172 def has_symbol(self) -> bool: 173 return bool(self._symbol) 174 175 @property 176 def aliases(self) -> Iterable[str]: 177 return self._aliases 178 179 def add_aliases(self, *alias: str) -> None: 180 alias = tuple(a for a in alias if a not in self._aliases) 181 self._aliases = self._aliases + alias 182 183 @property 184 def converter(self) -> Converter: 185 return self._converter 186 187 def __str__(self) -> str: 188 return self.name 189 190 191class PrefixDefinition(Definition): 192 """Definition of a prefix:: 193 194 <prefix>- = <amount> [= <symbol>] [= <alias>] [ = <alias> ] [...] 195 196 Example:: 197 198 deca- = 1e+1 = da- = deka- 199 """ 200 201 @classmethod 202 def from_string( 203 cls, definition: Union[str, PreprocessedDefinition], non_int_type: type = float 204 ) -> "PrefixDefinition": 205 if isinstance(definition, str): 206 definition = PreprocessedDefinition.from_string(definition) 207 208 aliases = tuple(alias.strip("-") for alias in definition.aliases) 209 if definition.symbol: 210 symbol = definition.symbol.strip("-") 211 else: 212 symbol = definition.symbol 213 214 try: 215 converter = ScaleConverter(numeric_parse(definition.value, non_int_type)) 216 except _NotNumeric as ex: 217 raise ValueError( 218 f"Prefix definition ('{definition.name}') must contain only numbers, not {ex.value}" 219 ) 220 221 return cls(definition.name.rstrip("-"), symbol, aliases, converter) 222 223 224class UnitDefinition(Definition): 225 """Definition of a unit:: 226 227 <canonical name> = <relation to another unit or dimension> [= <symbol>] [= <alias>] [ = <alias> ] [...] 228 229 Example:: 230 231 millennium = 1e3 * year = _ = millennia 232 233 Parameters 234 ---------- 235 reference : UnitsContainer 236 Reference units. 237 is_base : bool 238 Indicates if it is a base unit. 239 240 """ 241 242 def __init__( 243 self, 244 name: str, 245 symbol: Optional[str], 246 aliases: Iterable[str], 247 converter: Converter, 248 reference: Optional[UnitsContainer] = None, 249 is_base: bool = False, 250 ) -> None: 251 self.reference = reference 252 self.is_base = is_base 253 254 super().__init__(name, symbol, aliases, converter) 255 256 @classmethod 257 def from_string( 258 cls, definition: Union[str, PreprocessedDefinition], non_int_type: type = float 259 ) -> "UnitDefinition": 260 if isinstance(definition, str): 261 definition = PreprocessedDefinition.from_string(definition) 262 263 if ";" in definition.value: 264 [converter, modifiers] = definition.value.split(";", 1) 265 266 try: 267 modifiers = dict( 268 (key.strip(), numeric_parse(value, non_int_type)) 269 for key, value in (part.split(":") for part in modifiers.split(";")) 270 ) 271 except _NotNumeric as ex: 272 raise ValueError( 273 f"Unit definition ('{definition.name}') must contain only numbers in modifier, not {ex.value}" 274 ) 275 276 else: 277 converter = definition.value 278 modifiers = {} 279 280 converter = ParserHelper.from_string(converter, non_int_type) 281 282 if not any(_is_dim(key) for key in converter.keys()): 283 is_base = False 284 elif all(_is_dim(key) for key in converter.keys()): 285 is_base = True 286 else: 287 raise DefinitionSyntaxError( 288 "Cannot mix dimensions and units in the same definition. " 289 "Base units must be referenced only to dimensions. " 290 "Derived units must be referenced only to units." 291 ) 292 reference = UnitsContainer(converter) 293 294 if not modifiers: 295 converter = ScaleConverter(converter.scale) 296 297 elif "offset" in modifiers: 298 if modifiers.get("offset", 0.0) != 0.0: 299 converter = OffsetConverter(converter.scale, modifiers["offset"]) 300 else: 301 converter = ScaleConverter(converter.scale) 302 303 elif "logbase" in modifiers and "logfactor" in modifiers: 304 converter = LogarithmicConverter( 305 converter.scale, modifiers["logbase"], modifiers["logfactor"] 306 ) 307 308 else: 309 raise DefinitionSyntaxError("Unable to assign a converter to the unit") 310 311 return cls( 312 definition.name, 313 definition.symbol, 314 definition.aliases, 315 converter, 316 reference, 317 is_base, 318 ) 319 320 321class DimensionDefinition(Definition): 322 """Definition of a dimension:: 323 324 [dimension name] = <relation to other dimensions> 325 326 Example:: 327 328 [density] = [mass] / [volume] 329 """ 330 331 def __init__( 332 self, 333 name: str, 334 symbol: Optional[str], 335 aliases: Iterable[str], 336 converter: Optional[Converter], 337 reference: Optional[UnitsContainer] = None, 338 is_base: bool = False, 339 ) -> None: 340 self.reference = reference 341 self.is_base = is_base 342 343 super().__init__(name, symbol, aliases, converter=None) 344 345 @classmethod 346 def from_string( 347 cls, definition: Union[str, PreprocessedDefinition], non_int_type: type = float 348 ) -> "DimensionDefinition": 349 if isinstance(definition, str): 350 definition = PreprocessedDefinition.from_string(definition) 351 352 converter = ParserHelper.from_string(definition.value, non_int_type) 353 354 if not converter: 355 is_base = True 356 elif all(_is_dim(key) for key in converter.keys()): 357 is_base = False 358 else: 359 raise DefinitionSyntaxError( 360 "Base dimensions must be referenced to None. " 361 "Derived dimensions must only be referenced " 362 "to dimensions." 363 ) 364 reference = UnitsContainer(converter, non_int_type=non_int_type) 365 366 return cls( 367 definition.name, 368 definition.symbol, 369 definition.aliases, 370 converter, 371 reference, 372 is_base, 373 ) 374 375 376class AliasDefinition(Definition): 377 """Additional alias(es) for an already existing unit:: 378 379 @alias <canonical name or previous alias> = <alias> [ = <alias> ] [...] 380 381 Example:: 382 383 @alias meter = my_meter 384 """ 385 386 def __init__(self, name: str, aliases: Iterable[str]) -> None: 387 super().__init__(name=name, symbol=None, aliases=aliases, converter=None) 388 389 @classmethod 390 def from_string( 391 cls, definition: Union[str, PreprocessedDefinition], non_int_type: type = float 392 ) -> AliasDefinition: 393 394 if isinstance(definition, str): 395 definition = PreprocessedDefinition.from_string(definition) 396 397 name = definition.name[len("@alias ") :].lstrip() 398 return AliasDefinition(name, tuple(definition.rhs_parts)) 399