python-utils 4.1.1__tar.gz → 4.1.3__tar.gz
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.
- {python_utils-4.1.1 → python_utils-4.1.3}/PKG-INFO +1 -1
- python_utils-4.1.3/_python_utils_tests/test_formatters.py +377 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_generators.py +69 -5
- {python_utils-4.1.1 → python_utils-4.1.3}/pyproject.toml +1 -1
- {python_utils-4.1.1 → python_utils-4.1.3}/pyproject.toml.orig +1 -1
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/formatters.py +7 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/generators.py +15 -2
- python_utils-4.1.1/_python_utils_tests/test_formatters.py +0 -118
- {python_utils-4.1.1 → python_utils-4.1.3}/LICENSE +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/README.md +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/__init__.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/clock.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/requirements.txt +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_aio.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_aliases.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_containers.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_converters.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_decorators.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_import.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_import_footprint.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_lazy_imports.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_logged.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_logger.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_python_utils.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_time.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/_python_utils_tests/test_types.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/conftest.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/__about__.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/__init__.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/_aliases.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/aio.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/containers.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/converters.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/decorators.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/exceptions.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/import_.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/logger.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/loguru.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/py.typed +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/terminal.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/time.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/python_utils/types.py +0 -0
- {python_utils-4.1.1 → python_utils-4.1.3}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-utils
|
|
3
|
-
Version: 4.1.
|
|
3
|
+
Version: 4.1.3
|
|
4
4
|
Summary: Python Utils is a module with some convenient utilities not included with the standard Python install
|
|
5
5
|
Keywords: utils,utilities,helpers,typing,typed,async,lazy-imports
|
|
6
6
|
Author: Rick van Hattem
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Tests for the formatting helpers in ``python_utils.formatters``."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import types
|
|
5
|
+
import typing
|
|
6
|
+
from zoneinfo import ZoneInfo, available_timezones
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from python_utils import formatters
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize(
|
|
14
|
+
('days', 'expected'),
|
|
15
|
+
[
|
|
16
|
+
(30, '1 month ago'),
|
|
17
|
+
(35, '1 month and 5 days ago'),
|
|
18
|
+
(60, '2 months ago'),
|
|
19
|
+
(365, '1 year ago'),
|
|
20
|
+
(730, '2 years ago'),
|
|
21
|
+
],
|
|
22
|
+
)
|
|
23
|
+
def test_timesince_units_do_not_overlap(days: int, expected: str) -> None:
|
|
24
|
+
"""Take the weeks and days from what the larger units left over."""
|
|
25
|
+
delta: datetime.timedelta = datetime.timedelta(days=days)
|
|
26
|
+
assert formatters.timesince(delta) == expected
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize(
|
|
30
|
+
('delta', 'expected'),
|
|
31
|
+
[
|
|
32
|
+
(datetime.timedelta(seconds=-1), '1 second ago'),
|
|
33
|
+
(datetime.timedelta(seconds=-61), '1 minute and 1 second ago'),
|
|
34
|
+
(datetime.timedelta(days=-400), '1 year and 1 month ago'),
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
def test_timesince_negative_timedelta(
|
|
38
|
+
delta: datetime.timedelta, expected: str
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Describe a negative timedelta by its size, like a datetime."""
|
|
41
|
+
assert formatters.timesince(delta) == expected
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize(
|
|
45
|
+
'timezone',
|
|
46
|
+
[
|
|
47
|
+
datetime.timezone.utc,
|
|
48
|
+
datetime.timezone(datetime.timedelta(hours=5, minutes=30)),
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
def test_timesince_aware_datetime(timezone: datetime.timezone) -> None:
|
|
52
|
+
"""Compare a timezone-aware datetime with an aware current time."""
|
|
53
|
+
# Only the two largest units are shown, so the time this test takes
|
|
54
|
+
# cannot change the result.
|
|
55
|
+
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
|
|
56
|
+
moment: datetime.datetime = datetime.datetime.now(timezone) - age
|
|
57
|
+
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _UnknownOffset(datetime.tzinfo):
|
|
61
|
+
"""A timezone that does not know its UTC offset, as ``tzinfo`` allows."""
|
|
62
|
+
|
|
63
|
+
def utcoffset(self, dt: datetime.datetime | None) -> None:
|
|
64
|
+
"""Report the offset as unknown."""
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
def dst(self, dt: datetime.datetime | None) -> None:
|
|
68
|
+
"""Report the daylight saving offset as unknown."""
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
def tzname(self, dt: datetime.datetime | None) -> str:
|
|
72
|
+
"""Name the timezone."""
|
|
73
|
+
return 'unknown'
|
|
74
|
+
|
|
75
|
+
def fromutc(self, dt: datetime.datetime) -> datetime.datetime:
|
|
76
|
+
"""Use the UTC wall clock as the local one."""
|
|
77
|
+
return dt.replace(tzinfo=self)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _OwnOffsetDateTime(datetime.datetime):
|
|
81
|
+
"""A datetime subclass that reports an offset its tzinfo does not know."""
|
|
82
|
+
|
|
83
|
+
def utcoffset(self) -> datetime.timedelta:
|
|
84
|
+
"""Report a fixed offset of three hours."""
|
|
85
|
+
return datetime.timedelta(hours=3)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_timesince_datetime_subclass_with_own_offset() -> None:
|
|
89
|
+
"""Subtract a subclass with its own offset the way a datetime does."""
|
|
90
|
+
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
|
|
91
|
+
moment: datetime.datetime = _OwnOffsetDateTime.now(_UnknownOffset()) - age
|
|
92
|
+
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _ClockChange(datetime.tzinfo):
|
|
96
|
+
"""A zone that sets its clock back an hour at 2026-10-25 01:00 UTC.
|
|
97
|
+
|
|
98
|
+
One instance serves both sides of the change, as a ``dateutil`` zone
|
|
99
|
+
does, so two datetimes in it share their tzinfo.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
_CHANGE_UTC: datetime.datetime = datetime.datetime(2026, 10, 25, 1)
|
|
103
|
+
_CHANGE_WALL: datetime.datetime = datetime.datetime(2026, 10, 25, 2)
|
|
104
|
+
|
|
105
|
+
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta:
|
|
106
|
+
"""Report two hours before the change and one hour after it."""
|
|
107
|
+
if dt is None or dt.replace(tzinfo=None) < self._CHANGE_WALL:
|
|
108
|
+
return datetime.timedelta(hours=2)
|
|
109
|
+
return datetime.timedelta(hours=1)
|
|
110
|
+
|
|
111
|
+
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta:
|
|
112
|
+
"""Report the daylight saving part of the offset."""
|
|
113
|
+
return self.utcoffset(dt) - datetime.timedelta(hours=1)
|
|
114
|
+
|
|
115
|
+
def tzname(self, dt: datetime.datetime | None) -> str:
|
|
116
|
+
"""Name the timezone."""
|
|
117
|
+
return 'clock change'
|
|
118
|
+
|
|
119
|
+
def fromutc(self, dt: datetime.datetime) -> datetime.datetime:
|
|
120
|
+
"""Convert from UTC by the offset in force at that instant."""
|
|
121
|
+
if dt.replace(tzinfo=None) < self._CHANGE_UTC:
|
|
122
|
+
return dt + datetime.timedelta(hours=2)
|
|
123
|
+
return dt + datetime.timedelta(hours=1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_timesince_counts_elapsed_time_across_dst_in_any_zone(
|
|
127
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Count a clock change for a tzinfo that is not a ``ZoneInfo``."""
|
|
130
|
+
zone = _ClockChange()
|
|
131
|
+
# 23:00 at +02:00 is 21:00 UTC, six hours before the frozen current time.
|
|
132
|
+
moment = datetime.datetime(2026, 10, 24, 23, tzinfo=zone)
|
|
133
|
+
current = datetime.datetime(2026, 10, 25, 3, tzinfo=datetime.timezone.utc)
|
|
134
|
+
|
|
135
|
+
class FrozenDateTime(datetime.datetime):
|
|
136
|
+
"""Return the chosen instant as the current time."""
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def now(cls, tz: datetime.tzinfo | None = None) -> 'FrozenDateTime':
|
|
140
|
+
"""Keep the same instant while returning the requested timezone."""
|
|
141
|
+
return cls.fromtimestamp(current.timestamp(), tz)
|
|
142
|
+
|
|
143
|
+
monkeypatch.setattr(
|
|
144
|
+
formatters,
|
|
145
|
+
'datetime',
|
|
146
|
+
types.SimpleNamespace(
|
|
147
|
+
datetime=FrozenDateTime,
|
|
148
|
+
timedelta=datetime.timedelta,
|
|
149
|
+
timezone=datetime.timezone,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
assert formatters.timesince(moment) == '6 hours ago'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _LookAlike:
|
|
156
|
+
"""Not a datetime, but subtracts from one, as ``arrow.Arrow`` does."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, moment: datetime.datetime) -> None:
|
|
159
|
+
"""Wrap an aware datetime."""
|
|
160
|
+
self.moment: datetime.datetime = moment
|
|
161
|
+
self.tzinfo: datetime.tzinfo | None = moment.tzinfo
|
|
162
|
+
|
|
163
|
+
def __rsub__(self, other: datetime.datetime) -> datetime.timedelta:
|
|
164
|
+
"""Subtract the wrapped datetime from ``other``."""
|
|
165
|
+
return other - self.moment
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_timesince_datetime_look_alike() -> None:
|
|
169
|
+
"""Let an object that is not a datetime do its own subtraction."""
|
|
170
|
+
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
|
|
171
|
+
# Not one of the documented types, so typed as Any like the date test.
|
|
172
|
+
moment: typing.Any = _LookAlike(
|
|
173
|
+
datetime.datetime.now(datetime.timezone.utc) - age
|
|
174
|
+
)
|
|
175
|
+
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.parametrize(
|
|
179
|
+
('moment', 'current'),
|
|
180
|
+
[
|
|
181
|
+
(
|
|
182
|
+
datetime.datetime(2026, 11, 1, 1, 30, fold=0),
|
|
183
|
+
datetime.datetime(2026, 11, 1, 1, 30, fold=1),
|
|
184
|
+
),
|
|
185
|
+
(
|
|
186
|
+
datetime.datetime(2026, 11, 1, 1, 30, fold=1),
|
|
187
|
+
datetime.datetime(2026, 11, 1, 1, 30, fold=0),
|
|
188
|
+
),
|
|
189
|
+
(
|
|
190
|
+
datetime.datetime(2026, 3, 8, 1, 30),
|
|
191
|
+
datetime.datetime(2026, 3, 8, 3, 30),
|
|
192
|
+
),
|
|
193
|
+
(
|
|
194
|
+
datetime.datetime(2026, 3, 8, 3, 30),
|
|
195
|
+
datetime.datetime(2026, 3, 8, 1, 30),
|
|
196
|
+
),
|
|
197
|
+
],
|
|
198
|
+
ids=['fall-back-past', 'fall-back-future', 'spring-past', 'spring-future'],
|
|
199
|
+
)
|
|
200
|
+
@pytest.mark.skipif(
|
|
201
|
+
'America/New_York' not in available_timezones(),
|
|
202
|
+
reason='IANA timezone data is not available',
|
|
203
|
+
)
|
|
204
|
+
def test_timesince_counts_elapsed_time_across_dst(
|
|
205
|
+
moment: datetime.datetime,
|
|
206
|
+
current: datetime.datetime,
|
|
207
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Count elapsed time when both datetimes share a changing UTC offset."""
|
|
210
|
+
timezone = ZoneInfo('America/New_York')
|
|
211
|
+
current = current.replace(tzinfo=timezone)
|
|
212
|
+
moment = moment.replace(tzinfo=timezone)
|
|
213
|
+
|
|
214
|
+
class FrozenDateTime(datetime.datetime):
|
|
215
|
+
"""Return the chosen instant as the current time."""
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def now(cls, tz: datetime.tzinfo | None = None) -> 'FrozenDateTime':
|
|
219
|
+
"""Keep the same instant while returning the requested timezone."""
|
|
220
|
+
return cls.fromtimestamp(current.timestamp(), tz)
|
|
221
|
+
|
|
222
|
+
monkeypatch.setattr(
|
|
223
|
+
formatters,
|
|
224
|
+
'datetime',
|
|
225
|
+
types.SimpleNamespace(
|
|
226
|
+
datetime=FrozenDateTime,
|
|
227
|
+
timedelta=datetime.timedelta,
|
|
228
|
+
timezone=datetime.timezone,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
assert formatters.timesince(moment) == '1 hour ago'
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class _ElapsedRsub(datetime.datetime):
|
|
235
|
+
"""A subclass whose own subtraction counts elapsed time, as in pandas."""
|
|
236
|
+
|
|
237
|
+
def __rsub__(self, other: datetime.datetime) -> datetime.timedelta:
|
|
238
|
+
"""Subtract by UTC timestamp, so a DST change is counted."""
|
|
239
|
+
seconds: float = other.timestamp() - datetime.datetime.timestamp(self)
|
|
240
|
+
return datetime.timedelta(seconds=seconds)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.skipif(
|
|
244
|
+
'America/New_York' not in available_timezones(),
|
|
245
|
+
reason='IANA timezone data is not available',
|
|
246
|
+
)
|
|
247
|
+
def test_timesince_subclass_subtraction_counts_dst_once(
|
|
248
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Leave a subclass that counts elapsed time itself to count it once."""
|
|
251
|
+
timezone = ZoneInfo('America/New_York')
|
|
252
|
+
current = datetime.datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=timezone)
|
|
253
|
+
moment = _ElapsedRsub(2026, 11, 1, 1, 30, fold=0, tzinfo=timezone)
|
|
254
|
+
|
|
255
|
+
def frozen_now(tz: datetime.tzinfo | None = None) -> datetime.datetime:
|
|
256
|
+
"""Return the chosen instant as a plain datetime, like the clock."""
|
|
257
|
+
return datetime.datetime.fromtimestamp(current.timestamp(), tz)
|
|
258
|
+
|
|
259
|
+
monkeypatch.setattr(
|
|
260
|
+
formatters,
|
|
261
|
+
'datetime',
|
|
262
|
+
types.SimpleNamespace(
|
|
263
|
+
datetime=types.SimpleNamespace(now=frozen_now),
|
|
264
|
+
timedelta=datetime.timedelta,
|
|
265
|
+
timezone=datetime.timezone,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
assert formatters.timesince(moment) == '1 hour ago'
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.parametrize(
|
|
272
|
+
('moment', 'expected'),
|
|
273
|
+
[
|
|
274
|
+
(
|
|
275
|
+
datetime.datetime.min.replace(
|
|
276
|
+
tzinfo=datetime.timezone(datetime.timedelta(hours=1))
|
|
277
|
+
),
|
|
278
|
+
'2000 years and 3 months ago',
|
|
279
|
+
),
|
|
280
|
+
(
|
|
281
|
+
datetime.datetime.max.replace(
|
|
282
|
+
tzinfo=datetime.timezone(datetime.timedelta(hours=-1))
|
|
283
|
+
),
|
|
284
|
+
'8005 years and 3 months ago',
|
|
285
|
+
),
|
|
286
|
+
],
|
|
287
|
+
ids=['min-positive-offset', 'max-negative-offset'],
|
|
288
|
+
)
|
|
289
|
+
def test_timesince_aware_datetime_boundaries(
|
|
290
|
+
moment: datetime.datetime,
|
|
291
|
+
expected: str,
|
|
292
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Accept aware inputs whose corresponding UTC date is out of range."""
|
|
295
|
+
current = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
|
|
296
|
+
|
|
297
|
+
class FrozenDateTime(datetime.datetime):
|
|
298
|
+
"""Return the chosen instant as the current time."""
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def now(cls, tz: datetime.tzinfo | None = None) -> 'FrozenDateTime':
|
|
302
|
+
"""Keep the same instant while returning the requested timezone."""
|
|
303
|
+
return cls.fromtimestamp(current.timestamp(), tz)
|
|
304
|
+
|
|
305
|
+
monkeypatch.setattr(
|
|
306
|
+
formatters,
|
|
307
|
+
'datetime',
|
|
308
|
+
types.SimpleNamespace(
|
|
309
|
+
datetime=FrozenDateTime,
|
|
310
|
+
timedelta=datetime.timedelta,
|
|
311
|
+
timezone=datetime.timezone,
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
assert formatters.timesince(moment) == expected
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@pytest.mark.parametrize(
|
|
318
|
+
('name', 'expected'),
|
|
319
|
+
[
|
|
320
|
+
('SPAM_EGGS', 'spam_eggs'),
|
|
321
|
+
('HTTP_OK', 'http_ok'),
|
|
322
|
+
('MAX_SIZE_LIMIT', 'max_size_limit'),
|
|
323
|
+
('HTML5Parser', 'html5_parser'),
|
|
324
|
+
('toUTF8String', 'to_utf8_string'),
|
|
325
|
+
('ABCD_e', 'abcd_e'),
|
|
326
|
+
],
|
|
327
|
+
)
|
|
328
|
+
def test_camel_to_underscore_keeps_acronyms_whole(
|
|
329
|
+
name: str, expected: str
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Leave an acronym whole when an underscore or a digit follows it."""
|
|
332
|
+
assert formatters.camel_to_underscore(name) == expected
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@pytest.mark.parametrize(
|
|
336
|
+
('name', 'expected'),
|
|
337
|
+
[
|
|
338
|
+
('ABc', 'a_bc'),
|
|
339
|
+
('ABCd', 'ab_cd'),
|
|
340
|
+
('IPad', 'i_pad'),
|
|
341
|
+
('AName', 'a_name'),
|
|
342
|
+
('__ABc__', '__a_bc__'),
|
|
343
|
+
('XMLParser', 'xml_parser'),
|
|
344
|
+
],
|
|
345
|
+
)
|
|
346
|
+
def test_camel_to_underscore_splits_short_acronym_boundary(
|
|
347
|
+
name: str, expected: str
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Keep the final capital with the word following even a short acronym."""
|
|
350
|
+
assert formatters.camel_to_underscore(name) == expected
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@pytest.mark.parametrize(
|
|
354
|
+
('name', 'expected'),
|
|
355
|
+
[
|
|
356
|
+
('IDs', 'ids'),
|
|
357
|
+
('URLs', 'urls'),
|
|
358
|
+
('UserIDs', 'user_ids'),
|
|
359
|
+
('GetURLs', 'get_urls'),
|
|
360
|
+
('IDsAndNames', 'ids_and_names'),
|
|
361
|
+
('PDFs_v2', 'pdfs_v2'),
|
|
362
|
+
('ISpam', 'i_spam'),
|
|
363
|
+
('IPsec', 'i_psec'),
|
|
364
|
+
],
|
|
365
|
+
)
|
|
366
|
+
def test_camel_to_underscore_keeps_plural_acronyms_whole(
|
|
367
|
+
name: str, expected: str
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Keep a plural ``s`` that ends a word with the acronym before it."""
|
|
370
|
+
assert formatters.camel_to_underscore(name) == expected
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_timesince_date_raises_type_error() -> None:
|
|
374
|
+
"""Keep raising ``TypeError`` for a date, which has no time."""
|
|
375
|
+
today: typing.Any = datetime.date.today()
|
|
376
|
+
with pytest.raises(TypeError):
|
|
377
|
+
formatters.timesince(today)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for the batching helpers in ``python_utils.generators``."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import datetime
|
|
4
5
|
import gc
|
|
5
6
|
import sys
|
|
6
7
|
from types import SimpleNamespace
|
|
@@ -23,14 +24,22 @@ async def test_abatcher() -> None:
|
|
|
23
24
|
|
|
24
25
|
@pytest.mark.asyncio
|
|
25
26
|
@pytest.mark.parametrize(
|
|
26
|
-
'arrival_times',
|
|
27
|
+
('arrival_times', 'expected'),
|
|
27
28
|
[
|
|
28
|
-
(
|
|
29
|
-
|
|
29
|
+
(
|
|
30
|
+
(0, 8, 16, 24, 32, 40, 48, 56, 64, 72),
|
|
31
|
+
[[0, 1, 2], [3, 4], [5, 6], [7, 8], [9]],
|
|
32
|
+
),
|
|
33
|
+
(
|
|
34
|
+
(0, 10, 11, 21, 22, 32, 33, 43, 44, 54),
|
|
35
|
+
[[0, 1, 2], [3, 4], [5, 6], [7, 8], [9]],
|
|
36
|
+
),
|
|
30
37
|
],
|
|
31
38
|
)
|
|
32
39
|
async def test_abatcher_timed(
|
|
33
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
40
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
41
|
+
arrival_times: tuple[int, ...],
|
|
42
|
+
expected: list[list[int]],
|
|
34
43
|
) -> None:
|
|
35
44
|
"""Group async items into batches by time interval."""
|
|
36
45
|
now: float = 0.0
|
|
@@ -52,7 +61,7 @@ async def test_abatcher_timed(
|
|
|
52
61
|
async for batch in python_utils.abatcher(generator(), interval=10):
|
|
53
62
|
batches.append(batch)
|
|
54
63
|
|
|
55
|
-
assert batches ==
|
|
64
|
+
assert batches == expected
|
|
56
65
|
assert len(batches) == 5
|
|
57
66
|
|
|
58
67
|
|
|
@@ -89,6 +98,61 @@ async def test_abatcher_timed_with_timeout() -> None:
|
|
|
89
98
|
await batcher.__anext__()
|
|
90
99
|
|
|
91
100
|
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
@pytest.mark.parametrize('batch_size', [None, 3])
|
|
103
|
+
@pytest.mark.parametrize('interval', [10, datetime.timedelta(seconds=10)])
|
|
104
|
+
@pytest.mark.parametrize('arrival', [8.0, 10.0])
|
|
105
|
+
async def test_abatcher_partial_batch_keeps_deadline(
|
|
106
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
107
|
+
batch_size: int | None,
|
|
108
|
+
interval: int | datetime.timedelta,
|
|
109
|
+
arrival: float,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Flush a partial batch at its deadline, including an exact arrival."""
|
|
112
|
+
now: float = 0.0
|
|
113
|
+
monkeypatch.setattr(
|
|
114
|
+
python_utils.generators,
|
|
115
|
+
'time',
|
|
116
|
+
SimpleNamespace(perf_counter=lambda: now),
|
|
117
|
+
)
|
|
118
|
+
original_wait = asyncio.wait
|
|
119
|
+
|
|
120
|
+
async def wait(
|
|
121
|
+
pending: set[asyncio.Future[int]],
|
|
122
|
+
*,
|
|
123
|
+
timeout: float,
|
|
124
|
+
return_when: str,
|
|
125
|
+
) -> tuple[set[asyncio.Future[int]], set[asyncio.Future[int]]]:
|
|
126
|
+
"""Run ready items, or let the requested timeout elapse."""
|
|
127
|
+
nonlocal now
|
|
128
|
+
done, remaining = await original_wait(
|
|
129
|
+
pending, timeout=0, return_when=return_when
|
|
130
|
+
)
|
|
131
|
+
if not done:
|
|
132
|
+
now += timeout
|
|
133
|
+
return done, remaining
|
|
134
|
+
|
|
135
|
+
monkeypatch.setattr(asyncio, 'wait', wait)
|
|
136
|
+
|
|
137
|
+
async def generator() -> types.AsyncGenerator[int, None]:
|
|
138
|
+
"""Yield twice on the fake clock, then leave one item pending."""
|
|
139
|
+
nonlocal now
|
|
140
|
+
yield 0
|
|
141
|
+
now = arrival
|
|
142
|
+
yield 1
|
|
143
|
+
await asyncio.Event().wait()
|
|
144
|
+
|
|
145
|
+
batcher = python_utils.abatcher(
|
|
146
|
+
generator(), batch_size=batch_size, interval=interval
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
first = await batcher.__anext__()
|
|
150
|
+
assert first == [0, 1]
|
|
151
|
+
assert now == 10.0
|
|
152
|
+
finally:
|
|
153
|
+
await batcher.aclose()
|
|
154
|
+
|
|
155
|
+
|
|
92
156
|
async def blocked_generator(
|
|
93
157
|
started: asyncio.Event, closed: types.List[str]
|
|
94
158
|
) -> types.AsyncIterator[int]:
|
|
@@ -125,7 +125,7 @@ source-include = [
|
|
|
125
125
|
|
|
126
126
|
[project]
|
|
127
127
|
name = "python-utils"
|
|
128
|
-
version = "4.1.
|
|
128
|
+
version = "4.1.3"
|
|
129
129
|
description = "Python Utils is a module with some convenient utilities not included with the standard Python install"
|
|
130
130
|
readme = "README.md"
|
|
131
131
|
requires-python = ">=3.10"
|
|
@@ -12,7 +12,7 @@ source-include = ['_python_utils_tests/**/*.py', 'conftest.py', 'tox.ini']
|
|
|
12
12
|
|
|
13
13
|
[project]
|
|
14
14
|
name = 'python-utils'
|
|
15
|
-
version = "4.1.
|
|
15
|
+
version = "4.1.3" # static; the single source of truth. Bump via `uv version`.
|
|
16
16
|
description = 'Python Utils is a module with some convenient utilities not included with the standard Python install'
|
|
17
17
|
readme = 'README.md'
|
|
18
18
|
requires-python = '>=3.10'
|
|
@@ -155,6 +155,13 @@ def timesince(
|
|
|
155
155
|
# A date has no `tzinfo`, and no time to subtract either. It gets
|
|
156
156
|
# the same `TypeError` from the subtraction as it always did.
|
|
157
157
|
now = datetime.datetime.now(getattr(dt, 'tzinfo', None))
|
|
158
|
+
if now.utcoffset() is not None:
|
|
159
|
+
# Two aware datetimes that share a tzinfo subtract by wall clock,
|
|
160
|
+
# which misses a daylight saving change between them. The current
|
|
161
|
+
# time in UTC counts it. Only `now` is converted, because
|
|
162
|
+
# converting `dt` could overflow near `datetime.min` or
|
|
163
|
+
# `datetime.max`.
|
|
164
|
+
now = now.astimezone(datetime.timezone.utc)
|
|
158
165
|
diff = abs(now - dt)
|
|
159
166
|
|
|
160
167
|
# Every unit takes its share from what the larger units left over, so a
|
|
@@ -72,6 +72,9 @@ async def abatcher(
|
|
|
72
72
|
request for it. A source that is an async generator ends at that point.
|
|
73
73
|
A source with nothing on its way stays usable.
|
|
74
74
|
|
|
75
|
+
Items arriving within an interval share its deadline. Waiting for the
|
|
76
|
+
next item does not restart the interval of a nonempty batch.
|
|
77
|
+
|
|
75
78
|
Args:
|
|
76
79
|
generator: The async generator or iterator to batch.
|
|
77
80
|
batch_size (typing.Optional[int], optional): The number of items per
|
|
@@ -110,9 +113,13 @@ async def abatcher(
|
|
|
110
113
|
# `create_task` insists on a coroutine.
|
|
111
114
|
pending = {asyncio.ensure_future(generator.__anext__())}
|
|
112
115
|
|
|
116
|
+
timeout = interval_s
|
|
117
|
+
if interval and batch:
|
|
118
|
+
timeout = max(0.0, next_yield - time.perf_counter())
|
|
119
|
+
|
|
113
120
|
done, pending = await asyncio.wait(
|
|
114
121
|
pending,
|
|
115
|
-
timeout=
|
|
122
|
+
timeout=timeout,
|
|
116
123
|
return_when=asyncio.FIRST_COMPLETED,
|
|
117
124
|
)
|
|
118
125
|
|
|
@@ -132,7 +139,13 @@ async def abatcher(
|
|
|
132
139
|
# next item is flushed on its own once the old one ran out.
|
|
133
140
|
next_yield = time.perf_counter() + interval_s
|
|
134
141
|
|
|
135
|
-
|
|
142
|
+
# A wait that ends without an item ran out of the deadline, so the
|
|
143
|
+
# batch is due. An item that arrives at the deadline joins it.
|
|
144
|
+
if (
|
|
145
|
+
interval
|
|
146
|
+
and batch
|
|
147
|
+
and (not done or time.perf_counter() > next_yield)
|
|
148
|
+
):
|
|
136
149
|
yield batch
|
|
137
150
|
batch = []
|
|
138
151
|
# Always set the next yield time to the current time. If the
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
"""Tests for the formatting helpers in ``python_utils.formatters``."""
|
|
2
|
-
|
|
3
|
-
import datetime
|
|
4
|
-
import typing
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from python_utils import formatters
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@pytest.mark.parametrize(
|
|
12
|
-
('days', 'expected'),
|
|
13
|
-
[
|
|
14
|
-
(30, '1 month ago'),
|
|
15
|
-
(35, '1 month and 5 days ago'),
|
|
16
|
-
(60, '2 months ago'),
|
|
17
|
-
(365, '1 year ago'),
|
|
18
|
-
(730, '2 years ago'),
|
|
19
|
-
],
|
|
20
|
-
)
|
|
21
|
-
def test_timesince_units_do_not_overlap(days: int, expected: str) -> None:
|
|
22
|
-
"""Take the weeks and days from what the larger units left over."""
|
|
23
|
-
delta: datetime.timedelta = datetime.timedelta(days=days)
|
|
24
|
-
assert formatters.timesince(delta) == expected
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@pytest.mark.parametrize(
|
|
28
|
-
('delta', 'expected'),
|
|
29
|
-
[
|
|
30
|
-
(datetime.timedelta(seconds=-1), '1 second ago'),
|
|
31
|
-
(datetime.timedelta(seconds=-61), '1 minute and 1 second ago'),
|
|
32
|
-
(datetime.timedelta(days=-400), '1 year and 1 month ago'),
|
|
33
|
-
],
|
|
34
|
-
)
|
|
35
|
-
def test_timesince_negative_timedelta(
|
|
36
|
-
delta: datetime.timedelta, expected: str
|
|
37
|
-
) -> None:
|
|
38
|
-
"""Describe a negative timedelta by its size, like a datetime."""
|
|
39
|
-
assert formatters.timesince(delta) == expected
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@pytest.mark.parametrize(
|
|
43
|
-
'timezone',
|
|
44
|
-
[
|
|
45
|
-
datetime.timezone.utc,
|
|
46
|
-
datetime.timezone(datetime.timedelta(hours=5, minutes=30)),
|
|
47
|
-
],
|
|
48
|
-
)
|
|
49
|
-
def test_timesince_aware_datetime(timezone: datetime.timezone) -> None:
|
|
50
|
-
"""Compare a timezone-aware datetime with an aware current time."""
|
|
51
|
-
# Only the two largest units are shown, so the time this test takes
|
|
52
|
-
# cannot change the result.
|
|
53
|
-
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
|
|
54
|
-
moment: datetime.datetime = datetime.datetime.now(timezone) - age
|
|
55
|
-
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@pytest.mark.parametrize(
|
|
59
|
-
('name', 'expected'),
|
|
60
|
-
[
|
|
61
|
-
('SPAM_EGGS', 'spam_eggs'),
|
|
62
|
-
('HTTP_OK', 'http_ok'),
|
|
63
|
-
('MAX_SIZE_LIMIT', 'max_size_limit'),
|
|
64
|
-
('HTML5Parser', 'html5_parser'),
|
|
65
|
-
('toUTF8String', 'to_utf8_string'),
|
|
66
|
-
('ABCD_e', 'abcd_e'),
|
|
67
|
-
],
|
|
68
|
-
)
|
|
69
|
-
def test_camel_to_underscore_keeps_acronyms_whole(
|
|
70
|
-
name: str, expected: str
|
|
71
|
-
) -> None:
|
|
72
|
-
"""Leave an acronym whole when an underscore or a digit follows it."""
|
|
73
|
-
assert formatters.camel_to_underscore(name) == expected
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@pytest.mark.parametrize(
|
|
77
|
-
('name', 'expected'),
|
|
78
|
-
[
|
|
79
|
-
('ABc', 'a_bc'),
|
|
80
|
-
('ABCd', 'ab_cd'),
|
|
81
|
-
('IPad', 'i_pad'),
|
|
82
|
-
('AName', 'a_name'),
|
|
83
|
-
('__ABc__', '__a_bc__'),
|
|
84
|
-
('XMLParser', 'xml_parser'),
|
|
85
|
-
],
|
|
86
|
-
)
|
|
87
|
-
def test_camel_to_underscore_splits_short_acronym_boundary(
|
|
88
|
-
name: str, expected: str
|
|
89
|
-
) -> None:
|
|
90
|
-
"""Keep the final capital with the word following even a short acronym."""
|
|
91
|
-
assert formatters.camel_to_underscore(name) == expected
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@pytest.mark.parametrize(
|
|
95
|
-
('name', 'expected'),
|
|
96
|
-
[
|
|
97
|
-
('IDs', 'ids'),
|
|
98
|
-
('URLs', 'urls'),
|
|
99
|
-
('UserIDs', 'user_ids'),
|
|
100
|
-
('GetURLs', 'get_urls'),
|
|
101
|
-
('IDsAndNames', 'ids_and_names'),
|
|
102
|
-
('PDFs_v2', 'pdfs_v2'),
|
|
103
|
-
('ISpam', 'i_spam'),
|
|
104
|
-
('IPsec', 'i_psec'),
|
|
105
|
-
],
|
|
106
|
-
)
|
|
107
|
-
def test_camel_to_underscore_keeps_plural_acronyms_whole(
|
|
108
|
-
name: str, expected: str
|
|
109
|
-
) -> None:
|
|
110
|
-
"""Keep a plural ``s`` that ends a word with the acronym before it."""
|
|
111
|
-
assert formatters.camel_to_underscore(name) == expected
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def test_timesince_date_raises_type_error() -> None:
|
|
115
|
-
"""Keep raising ``TypeError`` for a date, which has no time."""
|
|
116
|
-
today: typing.Any = datetime.date.today()
|
|
117
|
-
with pytest.raises(TypeError):
|
|
118
|
-
formatters.timesince(today)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|