python-utils 2.5.6__py3-none-any.whl → 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,48 @@
1
- from __future__ import (absolute_import, division, print_function,
2
- unicode_literals)
1
+ """
2
+ This module provides utility functions for type conversion.
3
3
 
4
- import re
5
- import six
4
+ Functions::
5
+
6
+ to_int: Convert a string to an integer with optional regexp matching.
7
+ to_float: Convert a string to a float with optional regexp matching.
8
+ to_unicode: Convert objects to Unicode strings.
9
+ to_str: Convert objects to byte strings.
10
+ scale_1024: Scale a number down to a suitable size (powers of 1024).
11
+ remap: Remap a value from one range to another.
12
+ """
13
+
14
+ # Ignoring all mypy errors because mypy doesn't understand many modern typing
15
+ # constructs... please, use pyright instead if you can.
16
+ from __future__ import annotations
17
+
18
+ import decimal
6
19
  import math
20
+ import re
21
+ import typing
22
+
23
+ from python_utils import _aliases
7
24
 
25
+ #: Numeric type variable for ``remap`` (any ``int``, ``float`` or ``Decimal``).
26
+ _TN = typing.TypeVar('_TN', bound=_aliases.DecimalNumber)
8
27
 
9
- def to_int(input_, default=0, exception=(ValueError, TypeError), regexp=None):
10
- r'''
11
- Convert the given input to an integer or return default
28
+ #: Accepted ``regexp`` for ``to_int``/``to_float``: a compiled pattern, a
29
+ #: pattern string, ``True`` for the built-in digit pattern, or ``None``.
30
+ _RegexpType: typing.TypeAlias = (
31
+ re.Pattern[str] | str | typing.Literal[True] | None
32
+ )
33
+
34
+
35
+ def to_int(
36
+ input_: str | None = None,
37
+ default: int = 0,
38
+ exception: _aliases.ExceptionsType = (ValueError, TypeError),
39
+ regexp: _RegexpType = None,
40
+ ) -> int:
41
+ r"""
42
+ Convert the given input to an integer or return default.
12
43
 
13
44
  When trying to convert the exceptions given in the exception parameter
14
- are automatically catched and the default will be returned.
45
+ are automatically caught and the default will be returned.
15
46
 
16
47
  The regexp parameter allows for a regular expression to find the digits
17
48
  in a string.
@@ -25,6 +56,10 @@ def to_int(input_, default=0, exception=(ValueError, TypeError), regexp=None):
25
56
  0
26
57
  >>> to_int('1')
27
58
  1
59
+ >>> to_int('')
60
+ 0
61
+ >>> to_int()
62
+ 0
28
63
  >>> to_int('abc123')
29
64
  0
30
65
  >>> to_int('123abc')
@@ -63,40 +98,45 @@ def to_int(input_, default=0, exception=(ValueError, TypeError), regexp=None):
63
98
  Traceback (most recent call last):
64
99
  ...
65
100
  TypeError: unknown argument for regexp parameter: 123
66
- '''
67
-
101
+ """
68
102
  if regexp is True:
69
103
  regexp = re.compile(r'(\d+)')
70
- elif isinstance(regexp, six.string_types):
104
+ elif isinstance(regexp, str):
71
105
  regexp = re.compile(regexp)
72
106
  elif hasattr(regexp, 'search'):
73
107
  pass
74
108
  elif regexp is not None:
75
- raise TypeError('unknown argument for regexp parameter: %r' % regexp)
109
+ raise TypeError(f'unknown argument for regexp parameter: {regexp!r}')
76
110
 
77
111
  try:
78
- if regexp:
79
- match = regexp.search(input_)
80
- if match:
81
- input_ = match.groups()[-1]
82
- return int(input_)
112
+ if regexp and input_ and (match := regexp.search(input_)):
113
+ input_ = match.groups()[-1]
114
+
115
+ if input_ is None:
116
+ return default
117
+ else:
118
+ return int(input_)
83
119
  except exception:
84
120
  return default
85
121
 
86
122
 
87
- def to_float(input_, default=0, exception=(ValueError, TypeError),
88
- regexp=None):
89
- r'''
90
- Convert the given `input_` to an integer or return default
123
+ def to_float(
124
+ input_: str,
125
+ default: int = 0,
126
+ exception: _aliases.ExceptionsType = (ValueError, TypeError),
127
+ regexp: _RegexpType = None,
128
+ ) -> _aliases.Number:
129
+ r"""
130
+ Convert the given `input_` to an integer or return default.
91
131
 
92
132
  When trying to convert the exceptions given in the exception parameter
93
- are automatically catched and the default will be returned.
133
+ are automatically caught and the default will be returned.
94
134
 
95
135
  The regexp parameter allows for a regular expression to find the digits
96
136
  in a string.
97
137
  When True it will automatically match any digit in the string.
98
138
  When a (regexp) object (has a search method) is given, that will be used.
99
- WHen a string is given, re.compile will be run over it first
139
+ When a string is given, re.compile will be run over it first
100
140
 
101
141
  The last group of the regexp will be used as value
102
142
 
@@ -136,11 +176,10 @@ def to_float(input_, default=0, exception=(ValueError, TypeError),
136
176
  Traceback (most recent call last):
137
177
  ...
138
178
  TypeError: unknown argument for regexp parameter
139
- '''
140
-
179
+ """
141
180
  if regexp is True:
142
181
  regexp = re.compile(r'(\d+(\.\d+|))')
143
- elif isinstance(regexp, six.string_types):
182
+ elif isinstance(regexp, str):
144
183
  regexp = re.compile(regexp)
145
184
  elif hasattr(regexp, 'search'):
146
185
  pass
@@ -148,69 +187,78 @@ def to_float(input_, default=0, exception=(ValueError, TypeError),
148
187
  raise TypeError('unknown argument for regexp parameter')
149
188
 
150
189
  try:
151
- if regexp:
152
- match = regexp.search(input_)
153
- if match:
154
- input_ = match.group(1)
190
+ if regexp and (match := regexp.search(input_)):
191
+ input_ = match.group(1)
155
192
  return float(input_)
156
193
  except exception:
157
194
  return default
158
195
 
159
196
 
160
- def to_unicode(input_, encoding='utf-8', errors='replace'):
161
- '''Convert objects to unicode, if needed decodes string with the given
197
+ def to_unicode(
198
+ input_: _aliases.StringTypes,
199
+ encoding: str = 'utf-8',
200
+ errors: str = 'replace',
201
+ ) -> str:
202
+ """Convert objects to unicode, if needed decodes string with the given
162
203
  encoding and errors settings.
163
204
 
164
- :rtype: unicode
205
+ :rtype: str
165
206
 
166
207
  >>> to_unicode(b'a')
167
208
  'a'
168
209
  >>> to_unicode('a')
169
210
  'a'
170
- >>> to_unicode(u'a')
211
+ >>> to_unicode('a')
171
212
  'a'
172
- >>> class Foo(object): __str__ = lambda s: u'a'
213
+ >>> class Foo(object):
214
+ ... __str__ = lambda s: 'a'
173
215
  >>> to_unicode(Foo())
174
216
  'a'
175
217
  >>> to_unicode(Foo)
176
218
  "<class 'python_utils.converters.Foo'>"
177
- '''
178
- if isinstance(input_, six.binary_type):
219
+ """
220
+ if isinstance(input_, bytes):
179
221
  input_ = input_.decode(encoding, errors)
180
222
  else:
181
- input_ = six.text_type(input_)
223
+ input_ = str(input_)
182
224
  return input_
183
225
 
184
226
 
185
- def to_str(input_, encoding='utf-8', errors='replace'):
186
- '''Convert objects to string, encodes to the given encoding
227
+ def to_str(
228
+ input_: _aliases.StringTypes,
229
+ encoding: str = 'utf-8',
230
+ errors: str = 'replace',
231
+ ) -> bytes:
232
+ """Convert objects to string, encodes to the given encoding.
187
233
 
188
234
  :rtype: str
189
235
 
190
236
  >>> to_str('a')
191
237
  b'a'
192
- >>> to_str(u'a')
238
+ >>> to_str('a')
193
239
  b'a'
194
240
  >>> to_str(b'a')
195
241
  b'a'
196
- >>> class Foo(object): __str__ = lambda s: u'a'
242
+ >>> class Foo(object):
243
+ ... __str__ = lambda s: 'a'
197
244
  >>> to_str(Foo())
198
245
  'a'
199
246
  >>> to_str(Foo)
200
247
  "<class 'python_utils.converters.Foo'>"
201
- '''
202
- if isinstance(input_, six.binary_type):
203
- pass
204
- else:
248
+ """
249
+ if not isinstance(input_, bytes):
205
250
  if not hasattr(input_, 'encode'):
206
- input_ = six.text_type(input_)
251
+ input_ = str(input_)
207
252
 
208
253
  input_ = input_.encode(encoding, errors)
209
254
  return input_
210
255
 
211
256
 
212
- def scale_1024(x, n_prefixes):
213
- '''Scale a number down to a suitable size, based on powers of 1024.
257
+ def scale_1024(
258
+ x: _aliases.Number,
259
+ n_prefixes: int,
260
+ ) -> tuple[_aliases.Number, _aliases.Number]:
261
+ """Scale a number down to a suitable size, based on powers of 1024.
214
262
 
215
263
  Returns the scaled number and the power of 1024 used.
216
264
 
@@ -226,7 +274,7 @@ def scale_1024(x, n_prefixes):
226
274
  (0.5, 0)
227
275
  >>> scale_1024(1, 2)
228
276
  (1.0, 0)
229
- '''
277
+ """
230
278
  if x <= 0:
231
279
  power = 0
232
280
  else:
@@ -235,9 +283,83 @@ def scale_1024(x, n_prefixes):
235
283
  return scaled, power
236
284
 
237
285
 
238
- def remap(value, old_min, old_max, new_min, new_max):
286
+ @typing.overload
287
+ def remap(
288
+ value: decimal.Decimal,
289
+ old_min: decimal.Decimal | float,
290
+ old_max: decimal.Decimal | float,
291
+ new_min: decimal.Decimal | float,
292
+ new_max: decimal.Decimal | float,
293
+ ) -> decimal.Decimal:
294
+ """Overload: a ``Decimal`` ``value`` yields a ``Decimal`` result."""
295
+
296
+
297
+ @typing.overload
298
+ def remap(
299
+ value: decimal.Decimal | float,
300
+ old_min: decimal.Decimal,
301
+ old_max: decimal.Decimal | float,
302
+ new_min: decimal.Decimal | float,
303
+ new_max: decimal.Decimal | float,
304
+ ) -> decimal.Decimal:
305
+ """Overload: a ``Decimal`` ``old_min`` yields a ``Decimal`` result."""
306
+
307
+
308
+ @typing.overload
309
+ def remap(
310
+ value: decimal.Decimal | float,
311
+ old_min: decimal.Decimal | float,
312
+ old_max: decimal.Decimal,
313
+ new_min: decimal.Decimal | float,
314
+ new_max: decimal.Decimal | float,
315
+ ) -> decimal.Decimal:
316
+ """Overload: a ``Decimal`` ``old_max`` yields a ``Decimal`` result."""
317
+
318
+
319
+ @typing.overload
320
+ def remap(
321
+ value: decimal.Decimal | float,
322
+ old_min: decimal.Decimal | float,
323
+ old_max: decimal.Decimal | float,
324
+ new_min: decimal.Decimal,
325
+ new_max: decimal.Decimal | float,
326
+ ) -> decimal.Decimal:
327
+ """Overload: a ``Decimal`` ``new_min`` yields a ``Decimal`` result."""
328
+
329
+
330
+ @typing.overload
331
+ def remap(
332
+ value: decimal.Decimal | float,
333
+ old_min: decimal.Decimal | float,
334
+ old_max: decimal.Decimal | float,
335
+ new_min: decimal.Decimal | float,
336
+ new_max: decimal.Decimal,
337
+ ) -> decimal.Decimal:
338
+ """Overload: a ``Decimal`` ``new_max`` yields a ``Decimal`` result."""
339
+
340
+
341
+ # Note that float captures both int and float types so we don't need to
342
+ # specify them separately
343
+ @typing.overload
344
+ def remap(
345
+ value: float,
346
+ old_min: float,
347
+ old_max: float,
348
+ new_min: float,
349
+ new_max: float,
350
+ ) -> float:
351
+ """Overload: all-``float`` (or ``int``) inputs yield a ``float``."""
352
+
353
+
354
+ def remap( # pyright: ignore[reportInconsistentOverload]
355
+ value: _TN,
356
+ old_min: _TN,
357
+ old_max: _TN,
358
+ new_min: _TN,
359
+ new_max: _TN,
360
+ ) -> _TN:
239
361
  """
240
- remap a value from one range into another.
362
+ Remap a value from one range into another.
241
363
 
242
364
  >>> remap(500, 0, 1000, 0, 100)
243
365
  50
@@ -247,54 +369,112 @@ def remap(value, old_min, old_max, new_min, new_max):
247
369
  -750
248
370
  >>> remap(33, 0, 100, -500, 500)
249
371
  -170
372
+ >>> remap(decimal.Decimal('250.0'), 0.0, 1000.0, 0.0, 100.0)
373
+ Decimal('25.0')
250
374
 
251
375
  This is a great use case example. Take an AVR that has dB values the
252
376
  minimum being -80dB and the maximum being 10dB and you want to convert
253
- volume percent to the equilivint in that dB range
377
+ volume percent to the equivalent in that dB range
254
378
 
255
379
  >>> remap(46.0, 0.0, 100.0, -80.0, 10.0)
256
380
  -38.6
257
381
 
258
- Some edge cases to test
259
- >>> remap(0, 0, 0, 0, 0)
260
- 0
261
- >>> remap(0, 0, 0, 1, 0)
262
- 1
382
+ I added using decimal.Decimal so floating point math errors can be avoided.
383
+ Here is an example of a floating point math error
384
+ >>> 0.1 + 0.1 + 0.1
385
+ 0.30000000000000004
386
+
387
+ If floating point remaps need to be done my suggestion is to pass at least
388
+ one parameter as a `decimal.Decimal`. This will ensure that the output
389
+ from this function is accurate. I left passing `floats` for backwards
390
+ compatibility and there is no conversion done from float to
391
+ `decimal.Decimal` unless one of the passed parameters has a type of
392
+ `decimal.Decimal`. This will ensure that any existing code that uses this
393
+ function will work exactly how it has in the past.
263
394
 
264
- :param value: value to be converted
265
- :type value: int, float
395
+ Some edge cases to test
396
+ >>> remap(1, 0, 0, 1, 2)
397
+ Traceback (most recent call last):
398
+ ...
399
+ ValueError: Input range (0-0) is empty
266
400
 
267
- :param old_min: minimum of the range for the value that has been passed
268
- :type old_min: int, float
401
+ >>> remap(1, 1, 2, 0, 0)
402
+ Traceback (most recent call last):
403
+ ...
404
+ ValueError: Output range (0-0) is empty
405
+
406
+ Args:
407
+ value (int, float, decimal.Decimal): Value to be converted.
408
+ old_min (int, float, decimal.Decimal): Minimum of the range for the
409
+ value that has been passed.
410
+ old_max (int, float, decimal.Decimal): Maximum of the range for the
411
+ value that has been passed.
412
+ new_min (int, float, decimal.Decimal): The minimum of the new range.
413
+ new_max (int, float, decimal.Decimal): The maximum of the new range.
414
+
415
+ Returns: int, float, decimal.Decimal: Value that has been re-ranged. If
416
+ any of the parameters passed is a `decimal.Decimal`, all of the
417
+ parameters will be converted to `decimal.Decimal`. The same thing also
418
+ happens if one of the parameters is a `float`. Otherwise, all
419
+ parameters will get converted into an `int`. Technically, you can pass
420
+ a `str` of an integer and it will get converted. The returned value
421
+ type will be `decimal.Decimal` if any of the passed parameters are
422
+ `decimal.Decimal`, the return type will be `float` if any of the
423
+ passed parameters are a `float`, otherwise the returned type will be
424
+ `int`.
425
+ """
426
+ # Promote every argument to one common type: Decimal if any input is a
427
+ # Decimal, otherwise float if any is a float, otherwise int. This preserves
428
+ # the caller's precision (see above) instead of silently downcasting.
429
+ type_: type[_aliases.DecimalNumber]
430
+ if (
431
+ isinstance(value, decimal.Decimal)
432
+ or isinstance(old_min, decimal.Decimal)
433
+ or isinstance(old_max, decimal.Decimal)
434
+ or isinstance(new_min, decimal.Decimal)
435
+ or isinstance(new_max, decimal.Decimal)
436
+ ):
437
+ type_ = decimal.Decimal
438
+ elif (
439
+ isinstance(value, float)
440
+ or isinstance(old_min, float)
441
+ or isinstance(old_max, float)
442
+ or isinstance(new_min, float)
443
+ or isinstance(new_max, float)
444
+ ):
445
+ type_ = float
446
+ else:
447
+ type_ = int
269
448
 
270
- :param old_max: maximum of the range for the value that has been passed
271
- :type old_max: int, float
449
+ value = typing.cast(_TN, type_(value))
450
+ old_min = typing.cast(_TN, type_(old_min))
451
+ old_max = typing.cast(_TN, type_(old_max))
452
+ new_max = typing.cast(_TN, type_(new_max))
453
+ new_min = typing.cast(_TN, type_(new_min))
272
454
 
273
- :param new_min: the minimum of the new range
274
- :type new_min: int, float
455
+ # These might not be floats but the Python type system doesn't understand
456
+ # the generic type system in this case
457
+ old_range = typing.cast(float, old_max) - typing.cast(float, old_min)
458
+ new_range = typing.cast(float, new_max) - typing.cast(float, new_min)
275
459
 
276
- :param new_max: the maximum of the new range
277
- :type new_max: int, float
460
+ if old_range == 0:
461
+ raise ValueError(f'Input range ({old_min}-{old_max}) is empty')
278
462
 
279
- :return: value that has been re ranged, if the value is an int floor
280
- division is used so the returned value will always be rounded down
281
- to the closest whole number.
282
- :rtype: int, float
283
- """
284
- old_range = old_max - old_min
285
- new_range = new_max - new_min
286
463
  if new_range == 0:
287
- return 0
464
+ raise ValueError(f'Output range ({new_min}-{new_max}) is empty')
288
465
 
289
- if old_range == 0:
290
- new_value = new_min
466
+ # The current state of Python typing makes it impossible to use the
467
+ # generic type system in this case. Or so extremely verbose that it's not
468
+ # worth it.
469
+ new_value = (value - old_min) * new_range # type: ignore[operator] # pyright: ignore[reportOperatorIssue, reportUnknownVariableType]
470
+
471
+ # Integer inputs use floor division to keep the result integral; float and
472
+ # Decimal inputs use true division.
473
+ if type_ is int:
474
+ new_value //= old_range # pyright: ignore[reportUnknownVariableType] # pyrefly: ignore[unsupported-operation]
291
475
  else:
292
- new_value = (value - old_min) * new_range
293
- if isinstance(value, int):
294
- new_value = new_value // old_range
295
- else:
296
- new_value = new_value / old_range
476
+ new_value /= old_range # pyright: ignore[reportUnknownVariableType] # pyrefly: ignore[unsupported-operation]
297
477
 
298
- new_value += new_min
478
+ new_value += new_min # type: ignore[operator] # pyright: ignore[reportOperatorIssue, reportUnknownVariableType]
299
479
 
300
- return new_value
480
+ return typing.cast(_TN, new_value)