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