python-utils 4.2.1__tar.gz → 4.2.2__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.2.1 → python_utils-4.2.2}/PKG-INFO +1 -1
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_time.py +132 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/pyproject.toml +1 -1
- {python_utils-4.2.1 → python_utils-4.2.2}/pyproject.toml.orig +1 -1
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/time.py +47 -17
- {python_utils-4.2.1 → python_utils-4.2.2}/LICENSE +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/README.md +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/__init__.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/clock.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_aio.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_aliases.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_containers.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_converters.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_decorators.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_formatters.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_generators.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_import.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_import_footprint.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_lazy_imports.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_logged.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_logger.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_python_utils.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/_python_utils_tests/test_types.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/conftest.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/__about__.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/__init__.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/_aliases.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/aio.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/containers.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/converters.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/decorators.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/exceptions.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/formatters.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/generators.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/import_.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/logger.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/loguru.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/py.typed +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/terminal.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/python_utils/types.py +0 -0
- {python_utils-4.2.1 → python_utils-4.2.2}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-utils
|
|
3
|
-
Version: 4.2.
|
|
3
|
+
Version: 4.2.2
|
|
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
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import datetime
|
|
5
5
|
import itertools
|
|
6
6
|
import time
|
|
7
|
+
from types import SimpleNamespace
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
9
10
|
|
|
@@ -465,6 +466,137 @@ def test_format_time_extreme_timedeltas() -> None:
|
|
|
465
466
|
assert smallest == '-999999999 days, 0:00:00'
|
|
466
467
|
|
|
467
468
|
|
|
469
|
+
@pytest.mark.parametrize(
|
|
470
|
+
'value,precision,expected',
|
|
471
|
+
[
|
|
472
|
+
(
|
|
473
|
+
datetime.datetime(1960, 1, 2, 3, 4, 5, 678901),
|
|
474
|
+
datetime.timedelta(seconds=1),
|
|
475
|
+
'1960-01-02 03:04:05',
|
|
476
|
+
),
|
|
477
|
+
(
|
|
478
|
+
datetime.datetime(1900, 1, 2, 3, 4, 5, 678901),
|
|
479
|
+
datetime.timedelta(milliseconds=10),
|
|
480
|
+
'1900-01-02 03:04:05.670000',
|
|
481
|
+
),
|
|
482
|
+
(
|
|
483
|
+
datetime.datetime.min,
|
|
484
|
+
datetime.timedelta(seconds=1),
|
|
485
|
+
'0001-01-01 00:00:00',
|
|
486
|
+
),
|
|
487
|
+
(
|
|
488
|
+
datetime.datetime.max,
|
|
489
|
+
datetime.timedelta(seconds=1),
|
|
490
|
+
'9999-12-31 23:59:59',
|
|
491
|
+
),
|
|
492
|
+
],
|
|
493
|
+
)
|
|
494
|
+
def test_format_time_outside_platform_timestamp_range(
|
|
495
|
+
value: datetime.datetime,
|
|
496
|
+
precision: datetime.timedelta,
|
|
497
|
+
expected: str,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Format valid naive dates outside the platform's timestamp range."""
|
|
500
|
+
assert python_utils.format_time(value, precision) == expected
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class _NoTimestamp(datetime.datetime):
|
|
504
|
+
"""A datetime whose platform cannot give it a timestamp."""
|
|
505
|
+
|
|
506
|
+
def timestamp(self) -> float:
|
|
507
|
+
"""Fail the way an out-of-range platform conversion does."""
|
|
508
|
+
raise ValueError('year 0 is out of range')
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@pytest.mark.parametrize(
|
|
512
|
+
'precision',
|
|
513
|
+
[datetime.timedelta(0), 1, datetime.timedelta(days=999_999_999)],
|
|
514
|
+
)
|
|
515
|
+
def test_format_time_keeps_timestamp_error_without_wall_clock(
|
|
516
|
+
precision: datetime.timedelta,
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Raise the 4.2.1 error when exact arithmetic cannot help either."""
|
|
519
|
+
with pytest.raises(ValueError, match='year 0 is out of range'):
|
|
520
|
+
python_utils.format_time(_NoTimestamp(1, 1, 1), precision)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_format_time_keeps_timestamp_error_for_subclasses() -> None:
|
|
524
|
+
"""A subclass that cannot give a timestamp raises, as in 4.2.1."""
|
|
525
|
+
with pytest.raises(ValueError, match='year 0 is out of range'):
|
|
526
|
+
python_utils.format_time(_NoTimestamp(1, 1, 1))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def test_format_time_keeps_timestamp_error_for_aware() -> None:
|
|
530
|
+
"""Aware datetimes without a timestamp raise, as in 4.2.1."""
|
|
531
|
+
value = _NoTimestamp(2000, 1, 1, tzinfo=datetime.timezone.utc)
|
|
532
|
+
with pytest.raises(ValueError, match='year 0 is out of range'):
|
|
533
|
+
python_utils.format_time(value)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_format_time_keeps_max_when_wall_clock_overflows(
|
|
537
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
538
|
+
) -> None:
|
|
539
|
+
"""Return the 4.2.1 maximum when neither conversion can format it."""
|
|
540
|
+
|
|
541
|
+
class NoFromTimestamp(datetime.datetime):
|
|
542
|
+
"""Simulate a platform unable to convert the timestamp back."""
|
|
543
|
+
|
|
544
|
+
@classmethod
|
|
545
|
+
def fromtimestamp(
|
|
546
|
+
cls, timestamp: float, tz: datetime.tzinfo | None = None
|
|
547
|
+
) -> 'NoFromTimestamp':
|
|
548
|
+
"""Reject every timestamp."""
|
|
549
|
+
raise ValueError('year 0 is out of range')
|
|
550
|
+
|
|
551
|
+
monkeypatch.setattr(
|
|
552
|
+
python_utils.time,
|
|
553
|
+
'datetime',
|
|
554
|
+
SimpleNamespace(
|
|
555
|
+
datetime=NoFromTimestamp,
|
|
556
|
+
timedelta=datetime.timedelta,
|
|
557
|
+
date=datetime.date,
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
value = NoFromTimestamp(1, 1, 2)
|
|
561
|
+
result = python_utils.format_time(value, datetime.timedelta(days=7))
|
|
562
|
+
assert result == str(datetime.datetime.max)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@pytest.mark.parametrize('error', [OverflowError, OSError, ValueError])
|
|
566
|
+
@pytest.mark.parametrize('tzinfo', [None, datetime.timezone.utc])
|
|
567
|
+
def test_format_time_unavailable_fromtimestamp(
|
|
568
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
569
|
+
error: type[Exception],
|
|
570
|
+
tzinfo: datetime.tzinfo | None,
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Handle platform conversion failures on systems with a wider range."""
|
|
573
|
+
|
|
574
|
+
class LimitedDatetime(datetime.datetime):
|
|
575
|
+
"""Simulate a platform unable to convert the timestamp back."""
|
|
576
|
+
|
|
577
|
+
@classmethod
|
|
578
|
+
def fromtimestamp(
|
|
579
|
+
cls, timestamp: float, tz: datetime.tzinfo | None = None
|
|
580
|
+
) -> 'LimitedDatetime':
|
|
581
|
+
"""Reject dates outside the simulated platform range."""
|
|
582
|
+
raise error('timestamp out of range')
|
|
583
|
+
|
|
584
|
+
value = LimitedDatetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=tzinfo)
|
|
585
|
+
monkeypatch.setattr(
|
|
586
|
+
python_utils.time,
|
|
587
|
+
'datetime',
|
|
588
|
+
SimpleNamespace(
|
|
589
|
+
datetime=LimitedDatetime,
|
|
590
|
+
timedelta=datetime.timedelta,
|
|
591
|
+
date=datetime.date,
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
expected = (
|
|
595
|
+
'2000-01-02 03:04:05' if tzinfo is None else str(datetime.datetime.max)
|
|
596
|
+
)
|
|
597
|
+
assert python_utils.format_time(value) == expected
|
|
598
|
+
|
|
599
|
+
|
|
468
600
|
@pytest.mark.parametrize(
|
|
469
601
|
'delta',
|
|
470
602
|
[
|
|
@@ -126,7 +126,7 @@ source-include = [
|
|
|
126
126
|
|
|
127
127
|
[project]
|
|
128
128
|
name = "python-utils"
|
|
129
|
-
version = "4.2.
|
|
129
|
+
version = "4.2.2"
|
|
130
130
|
description = "Python Utils is a module with some convenient utilities not included with the standard Python install"
|
|
131
131
|
readme = "README.md"
|
|
132
132
|
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.2.
|
|
15
|
+
version = "4.2.2" # 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'
|
|
@@ -110,6 +110,49 @@ def delta_to_seconds_or_none(
|
|
|
110
110
|
return delta_to_seconds(interval)
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def _format_wall_clock(
|
|
114
|
+
timestamp: datetime.datetime, precision: datetime.timedelta
|
|
115
|
+
) -> str | None:
|
|
116
|
+
"""Truncate a naive datetime by exact arithmetic, None when it cannot.
|
|
117
|
+
|
|
118
|
+
Windows cannot convert pre-epoch naive dates to timestamps, and no
|
|
119
|
+
platform converts the datetime bounds. Wall-clock arithmetic relative
|
|
120
|
+
to the naive epoch handles both. Aware datetimes, subclasses (whose
|
|
121
|
+
own arithmetic and ``__str__`` may differ), precisions that are not a
|
|
122
|
+
usable timedelta and results outside the datetime range give None, so
|
|
123
|
+
the caller keeps the 4.2.1 outcome for them.
|
|
124
|
+
"""
|
|
125
|
+
if (
|
|
126
|
+
type(timestamp) is not datetime.datetime
|
|
127
|
+
or timestamp.tzinfo is not None
|
|
128
|
+
):
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return str(timestamp - (timestamp - epoch) % precision)
|
|
132
|
+
except (ArithmeticError, TypeError, ValueError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_datetime(
|
|
137
|
+
timestamp: datetime.datetime, precision: datetime.timedelta
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Format a datetime even outside the platform's timestamp range."""
|
|
140
|
+
try:
|
|
141
|
+
since_epoch = datetime.timedelta(seconds=timestamp.timestamp())
|
|
142
|
+
seconds = (since_epoch - since_epoch % precision).total_seconds()
|
|
143
|
+
except (OverflowError, OSError, ValueError):
|
|
144
|
+
# 4.2.1 raised here, which stays the answer without a wall clock.
|
|
145
|
+
wall_clock: str | None = _format_wall_clock(timestamp, precision)
|
|
146
|
+
if wall_clock is None:
|
|
147
|
+
raise
|
|
148
|
+
return wall_clock
|
|
149
|
+
try:
|
|
150
|
+
return str(datetime.datetime.fromtimestamp(seconds))
|
|
151
|
+
except (OverflowError, OSError, ValueError):
|
|
152
|
+
wall_clock = _format_wall_clock(timestamp, precision)
|
|
153
|
+
return str(datetime.datetime.max) if wall_clock is None else wall_clock
|
|
154
|
+
|
|
155
|
+
|
|
113
156
|
def format_time(
|
|
114
157
|
timestamp: _aliases.timestamp_type,
|
|
115
158
|
precision: datetime.timedelta = datetime.timedelta(seconds=1),
|
|
@@ -124,6 +167,8 @@ def format_time(
|
|
|
124
167
|
'0:00:01'
|
|
125
168
|
>>> format_time(datetime.datetime(2000, 1, 2, 3, 4, 5, 6))
|
|
126
169
|
'2000-01-02 03:04:05'
|
|
170
|
+
>>> format_time(datetime.datetime(1900, 1, 2, 3, 4, 5))
|
|
171
|
+
'1900-01-02 03:04:05'
|
|
127
172
|
>>> format_time(datetime.date(2000, 1, 2))
|
|
128
173
|
'2000-01-02'
|
|
129
174
|
>>> format_time(datetime.timedelta(seconds=3661))
|
|
@@ -152,23 +197,8 @@ def format_time(
|
|
|
152
197
|
# whole microseconds, which keeps the modulo exact. In float seconds
|
|
153
198
|
# `1.0 % 0.1` is just under 0.1 and the result drops a whole step.
|
|
154
199
|
return str(timestamp - timestamp % precision)
|
|
155
|
-
elif isinstance(timestamp, datetime.datetime):
|
|
156
|
-
|
|
157
|
-
if hasattr(timestamp, 'timestamp'):
|
|
158
|
-
seconds = timestamp.timestamp()
|
|
159
|
-
else:
|
|
160
|
-
seconds = timedelta_to_seconds(timestamp - epoch)
|
|
161
|
-
|
|
162
|
-
# Truncate the number to the given precision, in whole microseconds
|
|
163
|
-
# for the same reason as above
|
|
164
|
-
since_epoch: datetime.timedelta = datetime.timedelta(seconds=seconds)
|
|
165
|
-
seconds = (since_epoch - since_epoch % precision).total_seconds()
|
|
166
|
-
|
|
167
|
-
try: # pragma: no cover
|
|
168
|
-
dt = datetime.datetime.fromtimestamp(seconds)
|
|
169
|
-
except (ValueError, OSError): # pragma: no cover
|
|
170
|
-
dt = datetime.datetime.max
|
|
171
|
-
return str(dt)
|
|
200
|
+
elif isinstance(timestamp, datetime.datetime):
|
|
201
|
+
return _format_datetime(timestamp, precision)
|
|
172
202
|
elif isinstance(timestamp, datetime.date):
|
|
173
203
|
return str(timestamp)
|
|
174
204
|
elif timestamp is None:
|
|
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
|
|
File without changes
|