python-utils 3.8.2__py2.py3-none-any.whl → 3.9.1__py2.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,21 +1,45 @@
1
+ """
2
+ This module provides utility functions for type conversion.
3
+
4
+ Functions:
5
+ - to_int: Convert a string to an integer with optional regular expression
6
+ matching.
7
+ - to_float: Convert a string to a float with optional regular expression
8
+ matching.
9
+ - to_unicode: Convert objects to Unicode strings.
10
+ - to_str: Convert objects to byte strings.
11
+ - scale_1024: Scale a number down to a suitable size based on powers of
12
+ 1024.
13
+ - remap: Remap a value from one range to another.
14
+ """
15
+
16
+ # Ignoring all mypy errors because mypy doesn't understand many modern typing
17
+ # constructs... please, use pyright instead if you can.
18
+ from __future__ import annotations
19
+
1
20
  import decimal
2
21
  import math
3
22
  import re
4
23
  import typing
24
+ from typing import Union
5
25
 
6
26
  from . import types
7
27
 
8
28
  _TN = types.TypeVar('_TN', bound=types.DecimalNumber)
9
29
 
30
+ _RegexpType: types.TypeAlias = Union[
31
+ types.Pattern[str], str, types.Literal[True], None
32
+ ]
33
+
10
34
 
11
35
  def to_int(
12
- input_: typing.Optional[str] = None,
36
+ input_: str | None = None,
13
37
  default: int = 0,
14
38
  exception: types.ExceptionsType = (ValueError, TypeError),
15
- regexp: types.Optional[types.Pattern[str]] = None,
39
+ regexp: _RegexpType = None,
16
40
  ) -> int:
17
- r'''
18
- Convert the given input to an integer or return default
41
+ r"""
42
+ Convert the given input to an integer or return default.
19
43
 
20
44
  When trying to convert the exceptions given in the exception parameter
21
45
  are automatically catched and the default will be returned.
@@ -74,7 +98,7 @@ def to_int(
74
98
  Traceback (most recent call last):
75
99
  ...
76
100
  TypeError: unknown argument for regexp parameter: 123
77
- '''
101
+ """
78
102
  if regexp is True:
79
103
  regexp = re.compile(r'(\d+)')
80
104
  elif isinstance(regexp, str):
@@ -82,18 +106,17 @@ def to_int(
82
106
  elif hasattr(regexp, 'search'):
83
107
  pass
84
108
  elif regexp is not None:
85
- raise TypeError('unknown argument for regexp parameter: %r' % regexp)
109
+ raise TypeError(f'unknown argument for regexp parameter: {regexp!r}')
86
110
 
87
111
  try:
88
- if regexp and input_:
89
- if match := regexp.search(input_):
90
- input_ = match.groups()[-1]
112
+ if regexp and input_ and (match := regexp.search(input_)):
113
+ input_ = match.groups()[-1]
91
114
 
92
115
  if input_ is None:
93
116
  return default
94
117
  else:
95
118
  return int(input_)
96
- except exception: # type: ignore
119
+ except exception:
97
120
  return default
98
121
 
99
122
 
@@ -101,10 +124,10 @@ def to_float(
101
124
  input_: str,
102
125
  default: int = 0,
103
126
  exception: types.ExceptionsType = (ValueError, TypeError),
104
- regexp: types.Optional[types.Pattern[str]] = None,
127
+ regexp: _RegexpType = None,
105
128
  ) -> types.Number:
106
- r'''
107
- Convert the given `input_` to an integer or return default
129
+ r"""
130
+ Convert the given `input_` to an integer or return default.
108
131
 
109
132
  When trying to convert the exceptions given in the exception parameter
110
133
  are automatically catched and the default will be returned.
@@ -113,7 +136,7 @@ def to_float(
113
136
  in a string.
114
137
  When True it will automatically match any digit in the string.
115
138
  When a (regexp) object (has a search method) is given, that will be used.
116
- 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
117
140
 
118
141
  The last group of the regexp will be used as value
119
142
 
@@ -153,8 +176,7 @@ def to_float(
153
176
  Traceback (most recent call last):
154
177
  ...
155
178
  TypeError: unknown argument for regexp parameter
156
- '''
157
-
179
+ """
158
180
  if regexp is True:
159
181
  regexp = re.compile(r'(\d+(\.\d+|))')
160
182
  elif isinstance(regexp, str):
@@ -165,9 +187,8 @@ def to_float(
165
187
  raise TypeError('unknown argument for regexp parameter')
166
188
 
167
189
  try:
168
- if regexp:
169
- if match := regexp.search(input_):
170
- input_ = match.group(1)
190
+ if regexp and (match := regexp.search(input_)):
191
+ input_ = match.group(1)
171
192
  return float(input_)
172
193
  except exception:
173
194
  return default
@@ -178,7 +199,7 @@ def to_unicode(
178
199
  encoding: str = 'utf-8',
179
200
  errors: str = 'replace',
180
201
  ) -> str:
181
- '''Convert objects to unicode, if needed decodes string with the given
202
+ """Convert objects to unicode, if needed decodes string with the given
182
203
  encoding and errors settings.
183
204
 
184
205
  :rtype: str
@@ -187,14 +208,15 @@ def to_unicode(
187
208
  'a'
188
209
  >>> to_unicode('a')
189
210
  'a'
190
- >>> to_unicode(u'a')
211
+ >>> to_unicode('a')
191
212
  'a'
192
- >>> class Foo(object): __str__ = lambda s: u'a'
213
+ >>> class Foo(object):
214
+ ... __str__ = lambda s: 'a'
193
215
  >>> to_unicode(Foo())
194
216
  'a'
195
217
  >>> to_unicode(Foo)
196
218
  "<class 'python_utils.converters.Foo'>"
197
- '''
219
+ """
198
220
  if isinstance(input_, bytes):
199
221
  input_ = input_.decode(encoding, errors)
200
222
  else:
@@ -207,22 +229,23 @@ def to_str(
207
229
  encoding: str = 'utf-8',
208
230
  errors: str = 'replace',
209
231
  ) -> bytes:
210
- '''Convert objects to string, encodes to the given encoding
232
+ """Convert objects to string, encodes to the given encoding.
211
233
 
212
234
  :rtype: str
213
235
 
214
236
  >>> to_str('a')
215
237
  b'a'
216
- >>> to_str(u'a')
238
+ >>> to_str('a')
217
239
  b'a'
218
240
  >>> to_str(b'a')
219
241
  b'a'
220
- >>> class Foo(object): __str__ = lambda s: u'a'
242
+ >>> class Foo(object):
243
+ ... __str__ = lambda s: 'a'
221
244
  >>> to_str(Foo())
222
245
  'a'
223
246
  >>> to_str(Foo)
224
247
  "<class 'python_utils.converters.Foo'>"
225
- '''
248
+ """
226
249
  if not isinstance(input_, bytes):
227
250
  if not hasattr(input_, 'encode'):
228
251
  input_ = str(input_)
@@ -235,7 +258,7 @@ def scale_1024(
235
258
  x: types.Number,
236
259
  n_prefixes: int,
237
260
  ) -> types.Tuple[types.Number, types.Number]:
238
- '''Scale a number down to a suitable size, based on powers of 1024.
261
+ """Scale a number down to a suitable size, based on powers of 1024.
239
262
 
240
263
  Returns the scaled number and the power of 1024 used.
241
264
 
@@ -251,7 +274,7 @@ def scale_1024(
251
274
  (0.5, 0)
252
275
  >>> scale_1024(1, 2)
253
276
  (1.0, 0)
254
- '''
277
+ """
255
278
  if x <= 0:
256
279
  power = 0
257
280
  else:
@@ -260,14 +283,76 @@ def scale_1024(
260
283
  return scaled, power
261
284
 
262
285
 
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
+
295
+
296
+ @typing.overload
297
+ def remap(
298
+ value: decimal.Decimal | float,
299
+ old_min: decimal.Decimal,
300
+ old_max: decimal.Decimal | float,
301
+ new_min: decimal.Decimal | float,
302
+ new_max: decimal.Decimal | float,
303
+ ) -> decimal.Decimal: ...
304
+
305
+
306
+ @typing.overload
307
+ def remap(
308
+ value: decimal.Decimal | float,
309
+ old_min: decimal.Decimal | float,
310
+ old_max: decimal.Decimal,
311
+ new_min: decimal.Decimal | float,
312
+ new_max: decimal.Decimal | float,
313
+ ) -> decimal.Decimal: ...
314
+
315
+
316
+ @typing.overload
263
317
  def remap(
318
+ value: decimal.Decimal | float,
319
+ old_min: decimal.Decimal | float,
320
+ old_max: decimal.Decimal | float,
321
+ new_min: decimal.Decimal,
322
+ new_max: decimal.Decimal | float,
323
+ ) -> decimal.Decimal: ...
324
+
325
+
326
+ @typing.overload
327
+ def remap(
328
+ value: decimal.Decimal | float,
329
+ old_min: decimal.Decimal | float,
330
+ old_max: decimal.Decimal | float,
331
+ new_min: decimal.Decimal | float,
332
+ new_max: decimal.Decimal,
333
+ ) -> decimal.Decimal: ...
334
+
335
+
336
+ # Note that float captures both int and float types so we don't need to
337
+ # specify them separately
338
+ @typing.overload
339
+ def remap(
340
+ value: float,
341
+ old_min: float,
342
+ old_max: float,
343
+ new_min: float,
344
+ new_max: float,
345
+ ) -> float: ...
346
+
347
+
348
+ def remap( # pyright: ignore[reportInconsistentOverload]
264
349
  value: _TN,
265
350
  old_min: _TN,
266
351
  old_max: _TN,
267
352
  new_min: _TN,
268
353
  new_max: _TN,
269
354
  ) -> _TN:
270
- '''
355
+ """
271
356
  remap a value from one range into another.
272
357
 
273
358
  >>> remap(500, 0, 1000, 0, 100)
@@ -312,33 +397,26 @@ def remap(
312
397
  ...
313
398
  ValueError: Output range (0-0) is empty
314
399
 
315
- :param value: value to be converted
316
- :type value: int, float, decimal.Decimal
317
-
318
- :param old_min: minimum of the range for the value that has been passed
319
- :type old_min: int, float, decimal.Decimal
320
-
321
- :param old_max: maximum of the range for the value that has been passed
322
- :type old_max: int, float, decimal.Decimal
323
-
324
- :param new_min: the minimum of the new range
325
- :type new_min: int, float, decimal.Decimal
326
-
327
- :param new_max: the maximum of the new range
328
- :type new_max: int, float, decimal.Decimal
329
-
330
- :return: value that has been re ranged. if any of the parameters passed is
331
- a `decimal.Decimal` all of the parameters will be converted to
332
- `decimal.Decimal`. The same thing also happens if one of the
333
- parameters is a `float`. otherwise all parameters will get converted
334
- into an `int`. technically you can pass a `str` of an integer and it
335
- will get converted. The returned value type will be `decimal.Decimal`
336
- of any of the passed parameters ar `decimal.Decimal`, the return type
337
- will be `float` if any of the passed parameters are a `float` otherwise
338
- the returned type will be `int`.
339
-
340
- :rtype: int, float, decimal.Decimal
341
- '''
400
+ Args:
401
+ value (int, float, decimal.Decimal): Value to be converted.
402
+ old_min (int, float, decimal.Decimal): Minimum of the range for the
403
+ value that has been passed.
404
+ old_max (int, float, decimal.Decimal): Maximum of the range for the
405
+ value that has been passed.
406
+ new_min (int, float, decimal.Decimal): The minimum of the new range.
407
+ new_max (int, float, decimal.Decimal): The maximum of the new range.
408
+
409
+ Returns: int, float, decimal.Decimal: Value that has been re-ranged. If
410
+ any of the parameters passed is a `decimal.Decimal`, all of the
411
+ parameters will be converted to `decimal.Decimal`. The same thing also
412
+ happens if one of the parameters is a `float`. Otherwise, all
413
+ parameters will get converted into an `int`. Technically, you can pass
414
+ a `str` of an integer and it will get converted. The returned value
415
+ type will be `decimal.Decimal` if any of the passed parameters are
416
+ `decimal.Decimal`, the return type will be `float` if any of the
417
+ passed parameters are a `float`, otherwise the returned type will be
418
+ `int`.
419
+ """
342
420
  type_: types.Type[types.DecimalNumber]
343
421
  if (
344
422
  isinstance(value, decimal.Decimal)
@@ -356,7 +434,6 @@ def remap(
356
434
  or isinstance(new_max, float)
357
435
  ):
358
436
  type_ = float
359
-
360
437
  else:
361
438
  type_ = int
362
439
 
@@ -377,13 +454,16 @@ def remap(
377
454
  if new_range == 0:
378
455
  raise ValueError(f'Output range ({new_min}-{new_max}) is empty')
379
456
 
380
- new_value = (value - old_min) * new_range # type: ignore
457
+ # The current state of Python typing makes it impossible to use the
458
+ # generic type system in this case. Or so extremely verbose that it's not
459
+ # worth it.
460
+ new_value = (value - old_min) * new_range # type: ignore[operator] # pyright: ignore[reportOperatorIssue, reportUnknownVariableType]
381
461
 
382
- if type_ == int:
383
- new_value //= old_range # type: ignore
462
+ if type_ is int:
463
+ new_value //= old_range # pyright: ignore[reportUnknownVariableType]
384
464
  else:
385
- new_value /= old_range # type: ignore
465
+ new_value /= old_range # pyright: ignore[reportUnknownVariableType]
386
466
 
387
- new_value += new_min # type: ignore
467
+ new_value += new_min # type: ignore[operator] # pyright: ignore[reportOperatorIssue, reportUnknownVariableType]
388
468
 
389
469
  return types.cast(_TN, new_value)
@@ -1,17 +1,33 @@
1
+ """
2
+ This module provides various utility decorators for Python functions
3
+ and methods.
4
+
5
+ The decorators include:
6
+
7
+ 1. `set_attributes`: Sets attributes on functions and classes.
8
+ 2. `listify`: Converts any generator to a list or other collection.
9
+ 3. `sample`: Limits calls to a function based on a sample rate.
10
+ 4. `wraps_classmethod`: Wraps classmethods with type info from a
11
+ regular method.
12
+
13
+ Each decorator is designed to enhance the functionality of Python
14
+ functions and methods in a simple and reusable manner.
15
+ """
16
+
1
17
  import contextlib
2
18
  import functools
3
19
  import logging
4
20
  import random
21
+
5
22
  from . import types
6
23
 
7
24
  _T = types.TypeVar('_T')
8
- _TC = types.TypeVar('_TC', bound=types.Container[types.Any])
9
25
  _P = types.ParamSpec('_P')
10
26
  _S = types.TypeVar('_S', covariant=True)
11
27
 
12
28
 
13
29
  def set_attributes(**kwargs: types.Any) -> types.Callable[..., types.Any]:
14
- '''Decorator to set attributes on functions and classes
30
+ """Decorator to set attributes on functions and classes.
15
31
 
16
32
  A common usage for this pattern is the Django Admin where
17
33
  functions can get an optional short_description. To illustrate:
@@ -23,19 +39,19 @@ def set_attributes(**kwargs: types.Any) -> types.Callable[..., types.Any]:
23
39
 
24
40
  >>> @set_attributes(short_description='Name')
25
41
  ... def upper_case_name(self, obj):
26
- ... return ("%s %s" % (obj.first_name, obj.last_name)).upper()
42
+ ... return ('%s %s' % (obj.first_name, obj.last_name)).upper()
27
43
 
28
44
  The standard Django version:
29
45
 
30
46
  >>> def upper_case_name(obj):
31
- ... return ("%s %s" % (obj.first_name, obj.last_name)).upper()
47
+ ... return ('%s %s' % (obj.first_name, obj.last_name)).upper()
32
48
 
33
49
  >>> upper_case_name.short_description = 'Name'
34
50
 
35
- '''
51
+ """
36
52
 
37
53
  def _set_attributes(
38
- function: types.Callable[_P, _T]
54
+ function: types.Callable[_P, _T],
39
55
  ) -> types.Callable[_P, _T]:
40
56
  for key, value in kwargs.items():
41
57
  setattr(function, key, value)
@@ -46,14 +62,14 @@ def set_attributes(**kwargs: types.Any) -> types.Callable[..., types.Any]:
46
62
 
47
63
  def listify(
48
64
  collection: types.Callable[
49
- [types.Iterable[_T]], _TC
50
- ] = list, # type: ignore
65
+ [types.Iterable[_T]], types.Collection[_T]
66
+ ] = list,
51
67
  allow_empty: bool = True,
52
68
  ) -> types.Callable[
53
69
  [types.Callable[..., types.Optional[types.Iterable[_T]]]],
54
- types.Callable[..., _TC],
70
+ types.Callable[..., types.Collection[_T]],
55
71
  ]:
56
- '''
72
+ """
57
73
  Convert any generator to a list or other type of collection.
58
74
 
59
75
  >>> @listify()
@@ -97,12 +113,14 @@ def listify(
97
113
 
98
114
  >>> dict_generator()
99
115
  {'a': 1, 'b': 2}
100
- '''
116
+ """
101
117
 
102
118
  def _listify(
103
- function: types.Callable[..., types.Optional[types.Iterable[_T]]]
104
- ) -> types.Callable[..., _TC]:
105
- def __listify(*args: types.Any, **kwargs: types.Any) -> _TC:
119
+ function: types.Callable[..., types.Optional[types.Iterable[_T]]],
120
+ ) -> types.Callable[..., types.Collection[_T]]:
121
+ def __listify(
122
+ *args: types.Any, **kwargs: types.Any
123
+ ) -> types.Collection[_T]:
106
124
  result: types.Optional[types.Iterable[_T]] = function(
107
125
  *args, **kwargs
108
126
  )
@@ -122,8 +140,13 @@ def listify(
122
140
  return _listify
123
141
 
124
142
 
125
- def sample(sample_rate: float):
126
- '''
143
+ def sample(
144
+ sample_rate: float,
145
+ ) -> types.Callable[
146
+ [types.Callable[_P, _T]],
147
+ types.Callable[_P, types.Optional[_T]],
148
+ ]:
149
+ """
127
150
  Limit calls to a function based on given sample rate.
128
151
  Number of calls to the function will be roughly equal to
129
152
  sample_rate percentage.
@@ -135,10 +158,10 @@ def sample(sample_rate: float):
135
158
  ... return 1
136
159
 
137
160
  Calls to *demo_function* will be limited to 50% approximatly.
138
- '''
161
+ """
139
162
 
140
163
  def _sample(
141
- function: types.Callable[_P, _T]
164
+ function: types.Callable[_P, _T],
142
165
  ) -> types.Callable[_P, types.Optional[_T]]:
143
166
  @functools.wraps(function)
144
167
  def __sample(
@@ -152,7 +175,7 @@ def sample(sample_rate: float):
152
175
  function,
153
176
  args,
154
177
  kwargs,
155
- ) # noqa: E501
178
+ )
156
179
  return None
157
180
 
158
181
  return __sample
@@ -166,16 +189,16 @@ def wraps_classmethod(
166
189
  [
167
190
  types.Callable[types.Concatenate[types.Any, _P], _T],
168
191
  ],
169
- types.Callable[types.Concatenate[types.Type[_S], _P], _T],
192
+ types.Callable[types.Concatenate[_S, _P], _T],
170
193
  ]:
171
- '''
194
+ """
172
195
  Like `functools.wraps`, but for wrapping classmethods with the type info
173
- from a regular method
174
- '''
196
+ from a regular method.
197
+ """
175
198
 
176
199
  def _wraps_classmethod(
177
200
  wrapper: types.Callable[types.Concatenate[types.Any, _P], _T],
178
- ) -> types.Callable[types.Concatenate[types.Type[_S], _P], _T]:
201
+ ) -> types.Callable[types.Concatenate[_S, _P], _T]:
179
202
  # For some reason `functools.update_wrapper` fails on some test
180
203
  # runs but not while running actual code
181
204
  with contextlib.suppress(AttributeError):
@@ -1,3 +1,15 @@
1
+ """
2
+ This module provides utility functions for raising and reraising exceptions.
3
+
4
+ Functions:
5
+ raise_exception(exception_class, *args, **kwargs):
6
+ Returns a function that raises an exception of the given type with
7
+ the given arguments.
8
+
9
+ reraise(*args, **kwargs):
10
+ Reraises the current exception.
11
+ """
12
+
1
13
  from . import types
2
14
 
3
15
 
@@ -6,7 +18,7 @@ def raise_exception(
6
18
  *args: types.Any,
7
19
  **kwargs: types.Any,
8
20
  ) -> types.Callable[..., None]:
9
- '''
21
+ """
10
22
  Returns a function that raises an exception of the given type with the
11
23
  given arguments.
12
24
 
@@ -14,7 +26,7 @@ def raise_exception(
14
26
  Traceback (most recent call last):
15
27
  ...
16
28
  ValueError: spam
17
- '''
29
+ """
18
30
 
19
31
  def raise_(*args_: types.Any, **kwargs_: types.Any) -> types.Any:
20
32
  raise exception_class(*args, **kwargs)
@@ -23,4 +35,10 @@ def raise_exception(
23
35
 
24
36
 
25
37
  def reraise(*args: types.Any, **kwargs: types.Any) -> types.Any:
38
+ """
39
+ Reraises the current exception.
40
+
41
+ This function seems useless, but it can be useful when you need to pass
42
+ a callable to another function that raises an exception.
43
+ """
26
44
  raise
@@ -1,3 +1,19 @@
1
+ """
2
+ This module provides utility functions for formatting strings and dates.
3
+
4
+ Functions:
5
+ camel_to_underscore(name: str) -> str:
6
+ Convert camel case style naming to underscore/snake case style naming.
7
+
8
+ apply_recursive(function: Callable[[str], str], data: OptionalScope = None,
9
+ **kwargs: Any) -> OptionalScope:
10
+ Apply a function to all keys in a scope recursively.
11
+
12
+ timesince(dt: Union[datetime.datetime, datetime.timedelta],
13
+ default: str = 'just now') -> str:
14
+ Returns string representing 'time since' e.g. 3 days ago, 5 hours ago.
15
+ """
16
+
1
17
  # pyright: reportUnnecessaryIsInstance=false
2
18
  import datetime
3
19
 
@@ -5,7 +21,7 @@ from python_utils import types
5
21
 
6
22
 
7
23
  def camel_to_underscore(name: str) -> str:
8
- '''Convert camel case style naming to underscore/snake case style naming
24
+ """Convert camel case style naming to underscore/snake case style naming.
9
25
 
10
26
  If there are existing underscores they will be collapsed with the
11
27
  to-be-added underscores. Multiple consecutive capital letters will not be
@@ -21,7 +37,7 @@ def camel_to_underscore(name: str) -> str:
21
37
  '__spam_and_bacon__'
22
38
  >>> camel_to_underscore('__SpamANDBacon__')
23
39
  '__spam_and_bacon__'
24
- '''
40
+ """
25
41
  output: types.List[str] = []
26
42
  for i, c in enumerate(name):
27
43
  if i > 0:
@@ -47,14 +63,19 @@ def apply_recursive(
47
63
  data: types.OptionalScope = None,
48
64
  **kwargs: types.Any,
49
65
  ) -> types.OptionalScope:
50
- '''
51
- Apply a function to all keys in a scope recursively
66
+ """
67
+ Apply a function to all keys in a scope recursively.
52
68
 
53
69
  >>> apply_recursive(camel_to_underscore, {'SpamEggsAndBacon': 'spam'})
54
70
  {'spam_eggs_and_bacon': 'spam'}
55
- >>> apply_recursive(camel_to_underscore, {'SpamEggsAndBacon': {
56
- ... 'SpamEggsAndBacon': 'spam',
57
- ... }})
71
+ >>> apply_recursive(
72
+ ... camel_to_underscore,
73
+ ... {
74
+ ... 'SpamEggsAndBacon': {
75
+ ... 'SpamEggsAndBacon': 'spam',
76
+ ... }
77
+ ... },
78
+ ... )
58
79
  {'spam_eggs_and_bacon': {'spam_eggs_and_bacon': 'spam'}}
59
80
 
60
81
  >>> a = {'a_b_c': 123, 'def': {'DeF': 456}}
@@ -63,7 +84,7 @@ def apply_recursive(
63
84
  {'a_b_c': 123, 'def': {'de_f': 456}}
64
85
 
65
86
  >>> apply_recursive(camel_to_underscore, None)
66
- '''
87
+ """
67
88
  if data is None:
68
89
  return None
69
90
 
@@ -80,7 +101,7 @@ def timesince(
80
101
  dt: types.Union[datetime.datetime, datetime.timedelta],
81
102
  default: str = 'just now',
82
103
  ) -> str:
83
- '''
104
+ """
84
105
  Returns string representing 'time since' e.g.
85
106
  3 days ago, 5 hours ago etc.
86
107
 
@@ -121,7 +142,7 @@ def timesince(
121
142
  '1 hour and 2 minutes ago'
122
143
  >>> timesince(datetime.timedelta(seconds=3721))
123
144
  '1 hour and 2 minutes ago'
124
- '''
145
+ """
125
146
  if isinstance(dt, datetime.timedelta):
126
147
  diff = dt
127
148
  else:
@@ -140,11 +161,11 @@ def timesince(
140
161
 
141
162
  output: types.List[str] = []
142
163
  for period, singular, plural in periods:
143
- if int(period):
144
- if int(period) == 1:
145
- output.append('%d %s' % (period, singular))
146
- else:
147
- output.append('%d %s' % (period, plural))
164
+ int_period = int(period)
165
+ if int_period == 1:
166
+ output.append(f'{int_period} {singular}')
167
+ elif int_period:
168
+ output.append(f'{int_period} {plural}')
148
169
 
149
170
  if output:
150
171
  return f'{" and ".join(output[:2])} ago'