File: converters.py

package info (click to toggle)
python-utils 3.9.1-1
  • links: PTS, VCS
  • area: main
  • in suites: sid, trixie
  • size: 396 kB
  • sloc: python: 2,135; makefile: 19; sh: 5
file content (469 lines) | stat: -rw-r--r-- 13,906 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
"""
This module provides utility functions for type conversion.

Functions:
    - to_int: Convert a string to an integer with optional regular expression
    matching.
    - to_float: Convert a string to a float with optional regular expression
    matching.
    - to_unicode: Convert objects to Unicode strings.
    - to_str: Convert objects to byte strings.
    - scale_1024: Scale a number down to a suitable size based on powers of
    1024.
    - remap: Remap a value from one range to another.
"""

# Ignoring all mypy errors because mypy doesn't understand many modern typing
# constructs... please, use pyright instead if you can.
from __future__ import annotations

import decimal
import math
import re
import typing
from typing import Union

from . import types

_TN = types.TypeVar('_TN', bound=types.DecimalNumber)

_RegexpType: types.TypeAlias = Union[
    types.Pattern[str], str, types.Literal[True], None
]


def to_int(
    input_: str | None = None,
    default: int = 0,
    exception: types.ExceptionsType = (ValueError, TypeError),
    regexp: _RegexpType = None,
) -> int:
    r"""
    Convert the given input to an integer or return default.

    When trying to convert the exceptions given in the exception parameter
    are automatically catched and the default will be returned.

    The regexp parameter allows for a regular expression to find the digits
    in a string.
    When True it will automatically match any digit in the string.
    When a (regexp) object (has a search method) is given, that will be used.
    WHen a string is given, re.compile will be run over it first

    The last group of the regexp will be used as value

    >>> to_int('abc')
    0
    >>> to_int('1')
    1
    >>> to_int('')
    0
    >>> to_int()
    0
    >>> to_int('abc123')
    0
    >>> to_int('123abc')
    0
    >>> to_int('abc123', regexp=True)
    123
    >>> to_int('123abc', regexp=True)
    123
    >>> to_int('abc123abc', regexp=True)
    123
    >>> to_int('abc123abc456', regexp=True)
    123
    >>> to_int('abc123', regexp=re.compile(r'(\d+)'))
    123
    >>> to_int('123abc', regexp=re.compile(r'(\d+)'))
    123
    >>> to_int('abc123abc', regexp=re.compile(r'(\d+)'))
    123
    >>> to_int('abc123abc456', regexp=re.compile(r'(\d+)'))
    123
    >>> to_int('abc123', regexp=r'(\d+)')
    123
    >>> to_int('123abc', regexp=r'(\d+)')
    123
    >>> to_int('abc', regexp=r'(\d+)')
    0
    >>> to_int('abc123abc', regexp=r'(\d+)')
    123
    >>> to_int('abc123abc456', regexp=r'(\d+)')
    123
    >>> to_int('1234', default=1)
    1234
    >>> to_int('abc', default=1)
    1
    >>> to_int('abc', regexp=123)
    Traceback (most recent call last):
    ...
    TypeError: unknown argument for regexp parameter: 123
    """
    if regexp is True:
        regexp = re.compile(r'(\d+)')
    elif isinstance(regexp, str):
        regexp = re.compile(regexp)
    elif hasattr(regexp, 'search'):
        pass
    elif regexp is not None:
        raise TypeError(f'unknown argument for regexp parameter: {regexp!r}')

    try:
        if regexp and input_ and (match := regexp.search(input_)):
            input_ = match.groups()[-1]

        if input_ is None:
            return default
        else:
            return int(input_)
    except exception:
        return default


def to_float(
    input_: str,
    default: int = 0,
    exception: types.ExceptionsType = (ValueError, TypeError),
    regexp: _RegexpType = None,
) -> types.Number:
    r"""
    Convert the given `input_` to an integer or return default.

    When trying to convert the exceptions given in the exception parameter
    are automatically catched and the default will be returned.

    The regexp parameter allows for a regular expression to find the digits
    in a string.
    When True it will automatically match any digit in the string.
    When a (regexp) object (has a search method) is given, that will be used.
    When a string is given, re.compile will be run over it first

    The last group of the regexp will be used as value

    >>> '%.2f' % to_float('abc')
    '0.00'
    >>> '%.2f' % to_float('1')
    '1.00'
    >>> '%.2f' % to_float('abc123.456', regexp=True)
    '123.46'
    >>> '%.2f' % to_float('abc123', regexp=True)
    '123.00'
    >>> '%.2f' % to_float('abc0.456', regexp=True)
    '0.46'
    >>> '%.2f' % to_float('abc123.456', regexp=re.compile(r'(\d+\.\d+)'))
    '123.46'
    >>> '%.2f' % to_float('123.456abc', regexp=re.compile(r'(\d+\.\d+)'))
    '123.46'
    >>> '%.2f' % to_float('abc123.46abc', regexp=re.compile(r'(\d+\.\d+)'))
    '123.46'
    >>> '%.2f' % to_float('abc123abc456', regexp=re.compile(r'(\d+(\.\d+|))'))
    '123.00'
    >>> '%.2f' % to_float('abc', regexp=r'(\d+)')
    '0.00'
    >>> '%.2f' % to_float('abc123', regexp=r'(\d+)')
    '123.00'
    >>> '%.2f' % to_float('123abc', regexp=r'(\d+)')
    '123.00'
    >>> '%.2f' % to_float('abc123abc', regexp=r'(\d+)')
    '123.00'
    >>> '%.2f' % to_float('abc123abc456', regexp=r'(\d+)')
    '123.00'
    >>> '%.2f' % to_float('1234', default=1)
    '1234.00'
    >>> '%.2f' % to_float('abc', default=1)
    '1.00'
    >>> '%.2f' % to_float('abc', regexp=123)
    Traceback (most recent call last):
    ...
    TypeError: unknown argument for regexp parameter
    """
    if regexp is True:
        regexp = re.compile(r'(\d+(\.\d+|))')
    elif isinstance(regexp, str):
        regexp = re.compile(regexp)
    elif hasattr(regexp, 'search'):
        pass
    elif regexp is not None:
        raise TypeError('unknown argument for regexp parameter')

    try:
        if regexp and (match := regexp.search(input_)):
            input_ = match.group(1)
        return float(input_)
    except exception:
        return default


def to_unicode(
    input_: types.StringTypes,
    encoding: str = 'utf-8',
    errors: str = 'replace',
) -> str:
    """Convert objects to unicode, if needed decodes string with the given
    encoding and errors settings.

    :rtype: str

    >>> to_unicode(b'a')
    'a'
    >>> to_unicode('a')
    'a'
    >>> to_unicode('a')
    'a'
    >>> class Foo(object):
    ...     __str__ = lambda s: 'a'
    >>> to_unicode(Foo())
    'a'
    >>> to_unicode(Foo)
    "<class 'python_utils.converters.Foo'>"
    """
    if isinstance(input_, bytes):
        input_ = input_.decode(encoding, errors)
    else:
        input_ = str(input_)
    return input_


def to_str(
    input_: types.StringTypes,
    encoding: str = 'utf-8',
    errors: str = 'replace',
) -> bytes:
    """Convert objects to string, encodes to the given encoding.

    :rtype: str

    >>> to_str('a')
    b'a'
    >>> to_str('a')
    b'a'
    >>> to_str(b'a')
    b'a'
    >>> class Foo(object):
    ...     __str__ = lambda s: 'a'
    >>> to_str(Foo())
    'a'
    >>> to_str(Foo)
    "<class 'python_utils.converters.Foo'>"
    """
    if not isinstance(input_, bytes):
        if not hasattr(input_, 'encode'):
            input_ = str(input_)

        input_ = input_.encode(encoding, errors)
    return input_


def scale_1024(
    x: types.Number,
    n_prefixes: int,
) -> types.Tuple[types.Number, types.Number]:
    """Scale a number down to a suitable size, based on powers of 1024.

    Returns the scaled number and the power of 1024 used.

    Use to format numbers of bytes to KiB, MiB, etc.

    >>> scale_1024(310, 3)
    (310.0, 0)
    >>> scale_1024(2048, 3)
    (2.0, 1)
    >>> scale_1024(0, 2)
    (0.0, 0)
    >>> scale_1024(0.5, 2)
    (0.5, 0)
    >>> scale_1024(1, 2)
    (1.0, 0)
    """
    if x <= 0:
        power = 0
    else:
        power = min(int(math.log(x, 2) / 10), n_prefixes - 1)
    scaled = float(x) / (2 ** (10 * power))
    return scaled, power


@typing.overload
def remap(
    value: decimal.Decimal,
    old_min: decimal.Decimal | float,
    old_max: decimal.Decimal | float,
    new_min: decimal.Decimal | float,
    new_max: decimal.Decimal | float,
) -> decimal.Decimal: ...


@typing.overload
def remap(
    value: decimal.Decimal | float,
    old_min: decimal.Decimal,
    old_max: decimal.Decimal | float,
    new_min: decimal.Decimal | float,
    new_max: decimal.Decimal | float,
) -> decimal.Decimal: ...


@typing.overload
def remap(
    value: decimal.Decimal | float,
    old_min: decimal.Decimal | float,
    old_max: decimal.Decimal,
    new_min: decimal.Decimal | float,
    new_max: decimal.Decimal | float,
) -> decimal.Decimal: ...


@typing.overload
def remap(
    value: decimal.Decimal | float,
    old_min: decimal.Decimal | float,
    old_max: decimal.Decimal | float,
    new_min: decimal.Decimal,
    new_max: decimal.Decimal | float,
) -> decimal.Decimal: ...


@typing.overload
def remap(
    value: decimal.Decimal | float,
    old_min: decimal.Decimal | float,
    old_max: decimal.Decimal | float,
    new_min: decimal.Decimal | float,
    new_max: decimal.Decimal,
) -> decimal.Decimal: ...


# Note that float captures both int and float types so we don't need to
# specify them separately
@typing.overload
def remap(
    value: float,
    old_min: float,
    old_max: float,
    new_min: float,
    new_max: float,
) -> float: ...


def remap(  # pyright: ignore[reportInconsistentOverload]
    value: _TN,
    old_min: _TN,
    old_max: _TN,
    new_min: _TN,
    new_max: _TN,
) -> _TN:
    """
    remap a value from one range into another.

    >>> remap(500, 0, 1000, 0, 100)
    50
    >>> remap(250.0, 0.0, 1000.0, 0.0, 100.0)
    25.0
    >>> remap(-75, -100, 0, -1000, 0)
    -750
    >>> remap(33, 0, 100, -500, 500)
    -170
    >>> remap(decimal.Decimal('250.0'), 0.0, 1000.0, 0.0, 100.0)
    Decimal('25.0')

    This is a great use case example. Take an AVR that has dB values the
    minimum being -80dB and the maximum being 10dB and you want to convert
    volume percent to the equilivint in that dB range

    >>> remap(46.0, 0.0, 100.0, -80.0, 10.0)
    -38.6

    I added using decimal.Decimal so floating point math errors can be avoided.
    Here is an example of a floating point math error
    >>> 0.1 + 0.1 + 0.1
    0.30000000000000004

    If floating point remaps need to be done my suggstion is to pass at least
    one parameter as a `decimal.Decimal`. This will ensure that the output
    from this function is accurate. I left passing `floats` for backwards
    compatability and there is no conversion done from float to
    `decimal.Decimal` unless one of the passed parameters has a type of
    `decimal.Decimal`. This will ensure that any existing code that uses this
    funtion will work exactly how it has in the past.

    Some edge cases to test
    >>> remap(1, 0, 0, 1, 2)
    Traceback (most recent call last):
    ...
    ValueError: Input range (0-0) is empty

    >>> remap(1, 1, 2, 0, 0)
    Traceback (most recent call last):
    ...
    ValueError: Output range (0-0) is empty

    Args:
        value (int, float, decimal.Decimal): Value to be converted.
        old_min (int, float, decimal.Decimal): Minimum of the range for the
            value that has been passed.
        old_max (int, float, decimal.Decimal): Maximum of the range for the
            value that has been passed.
        new_min (int, float, decimal.Decimal): The minimum of the new range.
        new_max (int, float, decimal.Decimal): The maximum of the new range.

    Returns: int, float, decimal.Decimal: Value that has been re-ranged. If
        any of the parameters passed is a `decimal.Decimal`, all of the
        parameters will be converted to `decimal.Decimal`. The same thing also
        happens if one of the parameters is a `float`. Otherwise, all
        parameters will get converted into an `int`. Technically, you can pass
        a `str` of an integer and it will get converted. The returned value
        type will be `decimal.Decimal` if any of the passed parameters are
        `decimal.Decimal`, the return type will be `float` if any of the
        passed parameters are a `float`, otherwise the returned type will be
        `int`.
    """
    type_: types.Type[types.DecimalNumber]
    if (
        isinstance(value, decimal.Decimal)
        or isinstance(old_min, decimal.Decimal)
        or isinstance(old_max, decimal.Decimal)
        or isinstance(new_min, decimal.Decimal)
        or isinstance(new_max, decimal.Decimal)
    ):
        type_ = decimal.Decimal
    elif (
        isinstance(value, float)
        or isinstance(old_min, float)
        or isinstance(old_max, float)
        or isinstance(new_min, float)
        or isinstance(new_max, float)
    ):
        type_ = float
    else:
        type_ = int

    value = types.cast(_TN, type_(value))
    old_min = types.cast(_TN, type_(old_min))
    old_max = types.cast(_TN, type_(old_max))
    new_max = types.cast(_TN, type_(new_max))
    new_min = types.cast(_TN, type_(new_min))

    # These might not be floats but the Python type system doesn't understand
    # the generic type system in this case
    old_range = types.cast(float, old_max) - types.cast(float, old_min)
    new_range = types.cast(float, new_max) - types.cast(float, new_min)

    if old_range == 0:
        raise ValueError(f'Input range ({old_min}-{old_max}) is empty')

    if new_range == 0:
        raise ValueError(f'Output range ({new_min}-{new_max}) is empty')

    # The current state of Python typing makes it impossible to use the
    # generic type system in this case. Or so extremely verbose that it's not
    # worth it.
    new_value = (value - old_min) * new_range  # type: ignore[operator]  # pyright: ignore[reportOperatorIssue, reportUnknownVariableType]

    if type_ is int:
        new_value //= old_range  # pyright: ignore[reportUnknownVariableType]
    else:
        new_value /= old_range  # pyright: ignore[reportUnknownVariableType]

    new_value += new_min  # type: ignore[operator] # pyright: ignore[reportOperatorIssue, reportUnknownVariableType]

    return types.cast(_TN, new_value)